Writing and porting extensions for Seltzer

Seltzer (formerly WinClip) loads extensions written in the PopClip extension format and runs them on Windows. This guide is for extension authors: what is supported, what behaves differently from macOS, what Seltzer adds, and how to test. It describes Seltzer 0.1.0, which aims at compatibility with PopClip 2026.8.1 (build 6221); PopClip's own developer documentation remains the reference for the format itself.

Every statement here was checked against Seltzer's source code and tests.

Contents


Quick checklist for porting

Most extensions need no change. Check these points:

  1. macOS-only actions. serviceName, shortcutName and applescript* actions never run on Windows: they install but are hidden from the bar (and marked "macOS only" in Settings). Offer a Windows alternative with a URL scheme, a key combo, JavaScript or a PowerShell action — in a separate action, because the first action type in the precedence list wins (see Action types).
  2. Shell scripts. sh/bash/zsh scripts need Git for Windows or WSL and the user's opt-in setting; Mac programs (pbcopy, open, osascript, say) do not exist. Prefer JavaScript, or add a powershell action.
  3. Key combos. ⌘ and ⌃ both become Ctrl, ⌥ becomes Alt. Mac-specific shortcuts (⌘⇧Z redo, ⌃A line start) may mean something else on Windows; give a winKeyCombo for Windows.
  4. Icons. symbol: (SF Symbols) are replaced by similar Fluent/Lucide icons from a 92-name table; other names show the title. PopClip's own bundle: icons do not exist in Seltzer (it has its own 15). Prefer a package SVG, a text icon or iconify:.
  5. App filters. requiredApps/excludedApps take macOS bundle IDs, which Seltzer maps to Windows programs only for the apps in its alias table. Add requiredProcesses/excludedProcesses with Windows executable names.
  6. Plain HTTP is refused except for local hosts (see Network access).
  7. util.seltzer exists only in Seltzer: test for it (if (util.seltzer) …) so the extension still runs in PopClip. PopClip ignores all Seltzer-only config keys.

Packages, snippets and config files

Form Supported Notes
*.popclipext folder Yes Installed by copying (Explorer verb "Install in Seltzer", drop on Settings, Install from Folder…) or loaded in place as a developer folder.
*.popclipextz zip Yes The package folder at the zip root (Name.popclipext/…, also @shortcode.identifier.popclipext/…). Zip entries are validated (no paths outside the package, no Windows device names or alternate data streams; names Windows cannot store are skipped with a warning). Unix executable bits are recorded.
Snippets Yes Selected text (≤ 5000 characters; bar item Install "name"), .popcliptxt, .js, .ts, .yaml files, dropped or clipboard text. Parsing follows PopClip's rules exactly: marker # popclip (one optional space, case-insensitive, first occurrence anywhere), comment prefix, header ends at the first line without the prefix or a blank line, whole rest is YAML for an empty prefix, name test, "Don't use tabs in YAML", classification by file suffix > language > interpreter > #! > prefix (// → TypeScript, -- → AppleScript).
LZFSE modules (*.js.lzfse) Yes Decoded once (output capped at 16 MB) and cached; require("mathjs") finds mathjs.js.lzfse.
_Signature.plist Ignored Seltzer cannot verify PopClip signatures (see Trust).

Config files. Every root file named Config or Config.* is read, in the order Config.plist → Config.json → Config.yaml → others (by name; those are snippet-style files such as Config.js/Config.ts, whose suffix fixes the language) and the top-level keys are merged, later files winning. The 4-key Config.plist stub that the PopClip directory adds to downloads is therefore harmless.


Supported keys

Extension level: name (required; string or localisation table with en), identifier, description, keywords, icon, options, options title, entitlements (network, dynamic, script), action, actions, submenu, show as, auth service label, auth keychain, offers multiple instances, shell script rationale (shown in the trust prompt), module, language, app/apps, popclip version, macos version (read and ignored), plus every action key as a default for the actions.

Action level: title, identifier, icon (and the icon property keys preserve color, preserve aspect, flip x/flip y, move x/move y, scale, rotate, square, circle, search, strike, filled, monospaced, legacy icon options and mask: false), requirements, regex, required apps, excluded apps, before, after, stay visible, capture html, capture rtf, restore pasteboard, wants primary display, wants initial display, separator (in submenus), code (modules), app/apps, and the type keys below.

Ignored for compatibility: note, credits, version, long name, position, additional actions, options script file, options script interpreter, flags, stoppable, long running, comment, color.

Seltzer-only keys (PopClip ignores them):

Key Level Meaning
powershell / powershell file action PowerShell action (details)
cmd / batch file action Command Prompt action
native action Run a Seltzer native action: speech.say, print.text, qr.show, network.resolve, terminal.run, dictionary.show, outlook.compose (the selection as a new Outlook (Classic) email, displayed and never sent; a Subject: line at the top becomes the subject; option keepSignature, default on)
win key combo / win key combos action Key combo(s) to press on Windows instead of keyCombo(s) (details)
required processes / excluded processes action App filters with Windows executable names, AUMIDs or class: window classes
trim trailing newline action Remove trailing line breaks from a script's output before the after-step
windows version extension Minimum Windows version, e.g. "10.0.19041" (quote it in YAML); a newer value than the running Windows gives a warning
auth redirect port extension Fixed loopback port for auth()'s redirect URL, for services that need an exactly registered redirect URI

In JSON and JavaScript write them in camelCase: powershellFile, batchFile, winKeyCombo, requiredProcesses, trimTrailingNewline, windowsVersion, authRedirectPort.


Action types

The type of a static action is decided by the first group present, in this fixed order (a warning is logged when several are set):

url → keyCombo(s)/winKeyCombo(s) → serviceName → shortcutName → applescript → shellScript → javascript* → powershell* → cmd/batchFile → native**

Because the Seltzer-only types come last, an action with both shellScript and powershell is a shell-script action. Put a Windows variant in its own action (a macOS-only type is hidden automatically), or choose at run time in JavaScript (see the example under Shell scripts).

Type On Windows
URL (url, clean query, spaces as plus) Full. Template placeholders ***, {popclip text}, {popclip option <id>}. The text is trimmed, cleaned with clean query, quoted when the user holds Alt (verbatim search), percent-encoded (+ for spaces with spaces as plus; option values always %20). Opens in the browser the selection came from when it is a browser, else with the default handler; Shift opens a background tab. Schemes other than http/https must be on Seltzer's allow-list (below).
Key press (key combo, key combos, key combo target) Translated to Windows keys (details). app target: pressed only if the captured app is still in front (no per-process posting on Windows).
JavaScript (javascript, javascript file), TypeScript, module (module, code) Full (environment). Inline javascript bodies run as async () => { … }: top-level await and return work.
Shell script (shell script, shell script file, interpreter, stdin, shell mode) Out of process with interpreter mapping (details).
Service, Shortcut, AppleScript (service name, shortcut name, applescript, applescript file, applescript call) Not possible on Windows. The action is loaded, hidden from the bar and labelled "macOS only".
PowerShell, cmd, native Seltzer-only (see Supported keys).
No type A title-only action (shown disabled) or, with a submenu, a folder.

URL schemes. http, https, the user's Extra link schemes and a curated list (mailto, tel, sms, obsidian, logseq, notion, craftdocs, upnote, evernote, todoist, ticktick, spotify, deezer, tidal, zoommtg, zoomus, msteams, slack, discord, tg, whatsapp, translatium, bingmaps, ms-settings) are opened. claude (the Claude desktop app) is opened for the extensions that ship with Seltzer (Claude Code, Claude Chat and Rewrite, whose claude://claude.ai/new?q=rep%3A… link starts a chat with a rep: prompt) and for links you open yourself; another extension's claude: links need claude in your Extra link schemes, since such a link can start a Claude Code session in any folder with a prompt the extension wrote, and Seltzer asks no confirmation the way a browser does. Office URI schemes (ms-word: …), developer-tool schemes (vscode: …), search-ms, file: URLs other than existing local folders and documents, and any link whose target names a UNC path, a file: URL or a second host are refused — so a claude://code/new?…&folder= link opens with a folder on a local or mapped drive, but not with folder=\\server\share. Quotes, spaces and control characters are percent-encoded before a URL reaches its handler. A non-web link that no installed app opens fails with ERR_LAUNCH_FAILED and starts nothing.


Requirements, patterns and app filters

Requirements (default [text]; [] = always show): text, copy, cut (editable selection), paste (editable field and pasteable clipboard; works without a selection), formatting (rich-text control), url/httpurl (exactly one web URL; narrows the text to it), isurl (the text is a URL), urls/httpurls, email (exactly one; narrows), emails, path (an existing path on a local fixed drive; narrows and normalises — network and removable drives are never probed), option-<id>=<value> (booleans compare as 1/0), html (accepted, always true), each negatable with !. Links are detected with linkifyjs 4.3.3 plus the user's extra schemes.

regex strings are evaluated by the ICU engine built into Windows (icu.dll), the same family as macOS's NSRegularExpression, with flags 0; .NET's Regex is not used because it gives different answers on common patterns. The first match becomes matchedText (POPCLIP_TEXT) and its groups regexResult. A pattern that does not compile is a load error; a pattern that runs too long counts as no match. Regexes and link/e-mail/path detection are skipped for selections longer than 51,200 characters (the action is then hidden). A JavaScript RegExp exported by a module is evaluated in the extension's engine (50 ms budget). Keep patterns linear: they run on every selection. (?s)^.{1,200}$ counts code points, as in PopClip.

App filters. required apps/excluded apps accept macOS bundle IDs, which are mapped through Seltzer's alias table (browsers, Microsoft Office, VS Code, Slack, Notepad ↔ TextEdit, Explorer ↔ Finder, terminals ↔ Terminal, …), and also Windows executable names (*.exe, or a full path), AUMIDs and class:<window class>. required processes/excluded processes accept the same forms; use them for the Windows names so PopClip does not see them. app/apps with check installed: true show "X is required" with a Visit Website button only when every bundle ID is known and known to be absent; an unmapped ID never blocks the action.


Before and after steps, results

before: cut, copy, paste, paste-plain (reduces the clipboard to plain text permanently, then pastes). after: the before values plus:

After-step Clipboard Notes
copy-result Result copied (stays), "Copied" shown
paste-result Result pasted; it stays on the clipboard unless restore pasteboard: true or the user turned off Keep pasted results on the clipboard Shift-click copies instead; where nothing can be pasted it copies
show-result Nothing copied (unless the user turned on Copy shown results) One line, at most 160 characters
preview-result As show-result Clicking the preview pastes the full result; Shift-click copies
show-status — Tick or X
copy-selection The original selection, after any paste was consumed
popclip-appear — Shows the bar again after the action

Only strings (and ShellScriptResult, via toString()) feed *-result steps; undefined/null close the bar. An empty string is pasted or copied as empty; show and preview display nothing. Trailing line breaks: JavaScript strings are used as returned; classic shell/PowerShell/cmd output is used as printed (CRLF becomes LF) unless trim trailing newline; $ and ShellScriptResult.toString() strip them.


Options and secrets

Types string (multiline), boolean (default true; optional icon), multiple (values, value labels, allow none, allow other/allow custom), secret, password, heading; common keys label (defaults to the identifier), description (bare URLs and [label](url) links are clickable), default value, inset, hidden, keychain, migrate from. Values belong to one instance in the action list (the user can duplicate an extension unless offers multiple instances: false).


The JavaScript environment

Engine. JavaScript runs in Jint 4.16.3, a pure-.NET ECMAScript engine with modern syntax (classes, async/await, generators, destructuring, optional chaining, BigInt …). There is no JIT and no access to .NET from scripts.

One persistent engine per extension, as in PopClip: the module's top level runs once when the extension loads, and its state (a chat history, a cache) lives until the extension is reloaded — on install, update, reinstall, a change of its files with the developer watcher, or an app restart. Changing the extension's settings does not reload it (options are passed per call). Engines are created only for extensions in the enabled action list.

Module format. Package files are CommonJS modules, wrapped as (function (exports, require, module, __filename, __dirname, define, defineExtension) { … }). module.exports = {…}, defineExtension({…}) and AMD define(...) work. ES module syntax (import/export) and TypeScript are transpiled with sucrase 3.35.1 (types are stripped, not checked; output cached on disk). With export default, the default export is the extension, and mixing it with named exports is a load error. Action functions receive (input, options, context).

require. ./ and ../ relative to the current file; other names from the package root, then the bundled libraries; the extensions .js, .ts, .json (parsed) and .js.lzfse are tried; @popclip/<lib> is an alias of <lib>; a missing module returns undefined (PopClip's behaviour); paths outside the package return undefined; a package file shadows a bundled library of the same name.

Bundled libraries (PopClip's list at PopClip's versions, loaded on first require):

Library Version Licence
axios 1.12.2 MIT
buffer 6.0.3 MIT
case-anything 2.1.13 MIT
content-type 1.0.5 MIT
dom-serializer 2.0.0 MIT
emoji-regex 10.6.0 MIT
entities 7.0.0 BSD-2-Clause
fast-json-stable-stringify 2.1.0 MIT
fast-plist 0.1.3 MIT
htmlparser2 10.0.0 MIT
js-yaml 4.1.0 MIT
linkedom 0.18.12 ISC
linkifyjs 4.3.3 MIT
oauth-1.0a 2.2.6 MIT
rot13-cipher 1.0.0 MIT
sanitize-html 2.17.0 MIT
sucrase 3.35.1 MIT
turndown 7.2.1 MIT
valibot 1.1.0 MIT

turndown accepts HTML strings (it bundles a DOM parser) as well as DOM nodes.

Globals (in addition to ECMAScript's): popclip, util, pasteboard, print, sleep(ms), RichString, ShellScriptResult results, the $ shell tag; setTimeout/setInterval/clearTimeout/clearInterval; XMLHttpRequest; URL and URLSearchParams (core-js, WHATWG behaviour); Buffer (with base64url); atob/btoa; TextEncoder (UTF-8; encode() returns a Buffer); a minimal Blob; structuredClone; window (= globalThis, no DOM behind it); Intl.

Seltzer-only extras: a console whose methods write to the extension's debug log like print() (PopClip has no console; use print() in code that must run in both), and extra arguments to setTimeout/setInterval are passed to the handler.

Not available: fetch (use XMLHttpRequest or axios), TextDecoder (use Buffer's toString()), the DOM (document, DOMParser; use linkedom or htmlparser2), DOMException (errors carry its name and code), WebAssembly, Node's fs/child_process (use $/runShellScript with the script entitlement).

Intl and dates. Collation (localeCompare, Intl.Collator, including numeric sorting of very long digit runs), Intl.DateTimeFormat, Date.prototype.toLocale*String, Intl.Segmenter and locale-aware upper/lower case use Windows' ICU, with the user's Windows regional format as the default locale and the Windows time zone (changes are picked up while running). Intl.NumberFormat and any other Intl objects are Jint's own (based on .NET culture data). util.timeZoneInfo.abbreviation comes from ICU (GMT+1 style where no abbreviation exists).

Phases. As in PopClip:

Concurrency. One action runs per extension at a time; a second click while one runs fails with "… is still running" (other extensions are not affected). An action ends when its promise has settled and every timer, request and child process it started has finished; clicking the spinner cancels all of them. Timers created outside an action are engine-scoped (at most 64 at once).

Budgets.

Budget Value
Time a script may run without yielding (per slice of an action, a promise continuation or a sign-in) 5 s (Settings → Advanced → JavaScript time slice)
Loading a module 60 s or 4 × the slice
Population / submenu call 150 ms; module regex 50 ms
Memory 64 MB of new live objects per engine entry (loads: 4 ×, at least 256 MB)
Statements per entry 200 million
Recursion depth 400 calls
Regular expression, per match 500 ms (Jint gives a non-Unicode new RegExp(…) created at run time 5 s)
Overall action duration none (user-cancellable), as in PopClip

A script that exceeds a budget fails with "The script took too long to run." (or the memory message); the engine and its state survive.

Known Jint differences. Regular-expression literals and RegExp objects are run by Jint's translation to .NET's regex engine: rare constructs may behave differently from JavaScriptCore. Seltzer corrects the ones that broke real extensions: strings iterate by code point (spread, for…of, Array.from), String.prototype.replace with a callback receives undefined for groups that did not participate (also with the u flag), and expressions nested more than 256 levels deep (such as Babel's exports.a = exports.b = … = void 0 chains) are rewritten so they load. Deeply nested statements still fail to load.

TypeScript typings. One file, js/types/seltzer.d.ts (@seltzer/types, not yet published; installed next to Seltzer.exe as extensions\types\seltzer.d.ts, so a copy of that works without the package) declares the Seltzer additions on top of PopClip's own @popclip/types: util.seltzer, the Seltzer-only config keys, console and the extra timer arguments. js/types/README.md shows the tsconfig.json to use.


The popclip, util and pasteboard APIs on Windows

Everything in PopClip's JavaScript API is present; this table lists only what differs or needs a Windows explanation.

API On Windows
popclip.input text, matchedText, regexResult, data.urls/nonHttpUrls/emails/paths (with ranges), isUrl, and with captureHtml/captureRtf: html (sanitised), xhtml, markdown (turndown, ATX headings), rtf. content holds clipboard content keyed by type (public.utf8-plain-text, public.html, public.rtf) when the text came from the clipboard. HTML comes from the app's clipboard HTML (the CF_HTML "HTML Format"); when the clipboard cannot be used, it is generated from the plain text.
popclip.context hasFormatting, canPaste, canCopy, canCut, appName (from the program's version info), appIdentifier (the mapped macOS bundle ID for known apps, e.g. com.google.Chrome, else the executable name), browserUrl/browserTitle (resolved on first read from the browser's accessibility tree; empty for private windows unless the user allows it). Seltzer adds appExecutable (chrome.exe), appUserModelId and windowClass.
popclip.modifiers command = Ctrl, option = Alt, shift = Shift; control is always false for clicks; the Windows key is not reported. Keys held with Return in keyboard mode count.
pasteText, pasteContent Paste with Ctrl+V (Shift+Insert in terminals that need it; WM_PASTE for classic edit controls); {restore: true} restores the previous clipboard once the app has read the paste. Where nothing can be pasted, or with Shift held, the text is copied instead ("Copied"). Content types: public.utf8-plain-text, public.html, public.rtf.
copyText, copyContent, performCommand As in PopClip; performCommand("copy" \| "cut" \| "paste", {transform: "plain"}).
showText style: "compact" (one line in the bar) or "large" (Large Type overlay); preview: true makes it clickable.
pressKey, pressKeys See Key combos. target: "session" (default), "hid" (scan codes) or "app" (only if the captured app is still in front). Combos with the Windows key are refused for extensions that are not built in, hash-verified or confirmed by the user.
openUrl(url, {app, activate, backgroundTab}) app may be a macOS bundle ID that Seltzer maps to an installed browser (com.google.Chrome), an executable name (chrome.exe), a ProgId (ChromeHTML) or an AUMID; for a non-web scheme also the app registered for that scheme. It never starts an arbitrary program; otherwise the promise rejects with code: "ERR_APP_NOT_FOUND". Unspecified backgroundTab follows the Shift key. Links from a private window open privately, or reject with ERR_PRIVATE_WINDOW_UNAVAILABLE (and browserName) when the browser cannot. Other codes: ERR_URL_SCHEME_NOT_ALLOWED, ERR_LAUNCH_FAILED. A URL object's + in the query is sent as %20.
openTemplateUrl Host defaults as in PopClip: verbatim = Alt, copy = the user's Copy the text when searching, background tab = Shift.
revealFile(path) Returns a promise. ~ and ~/… expand to the user profile; the path must be absolute; a missing local path throws at once.
share(serviceName, items) Opens the Windows share sheet. Accepted names: "system" and Apple's generic ones (com.apple.share.Mail.compose, com.apple.share.Messages.window, com.apple.share.AirDrop.send …); other names throw "not recognized".
runShellScript, runShellScriptFile, $ Need script. Options interpreter (required for runShellScript; a file uses its #! line or extension otherwise), shellMode, env, stdin, arguments, prefix; the $ tag also quote. $ uses the given interpreter, else bash (when POSIX shells are allowed and Git Bash/WSL exists), else PowerShell, and quotes interpolated values for that shell (set -euo pipefail prefix for POSIX shells). Batch-file arguments containing & \| < > ^ % " or line breaks are refused with a TypeError — pass such text through stdin or env.
runAppleScript*, runShortcut, performService Throw "… is not available on Windows".
settingsRequiredError, signInRequiredError As in PopClip. A thrown error whose message starts with "settings error" or "not signed in" also opens the settings.
auth(info, flow) flow(url, params, expect) opens the default browser (never a private window) and waits (10 minutes at most) for the redirect to http://localhost:<port>/callback/<identifier>/<state>/auth on a loopback listener (random port unless authRedirectPort); the state is a 128-bit random value. The Settings sheet stores the result (secret, label, expiresIn).
util.localize Translates Cut, Copy, Paste, Search, Delete, Select All, Open Link(s), Line(s), Word(s), Character(s) into 16 languages, following Seltzer's UI language (Windows' otherwise); other strings are returned unchanged.
util.hasDictionaryDefinition, util.getDictionaryDefinition English only, from the bundled Open English WordNet 2025 (headword, part of speech, glosses, examples, synonyms).
util.getSpellingLanguages, checkSpelling, getSpellingGuesses, getPreferredSpellingLanguages The Windows Spell Checking API; codes like en_US (tags like en-US also work).
util.localeInfo, util.timeZoneInfo From the Windows regional settings and time zone, re-read when they change.
util.htmlToMarkdown, util.cleanHtml Run turndown and sanitize-html in a shared engine; turndown options must be JSON-serialisable (function options are dropped).
util.clarify, hash, hmac, base64*, buildQuery, parseQuery, getRandomValues (≤ 65536 bytes), randomUniform, randomUuid, constant As in PopClip.
pasteboard.text, pasteboard.content The Windows clipboard, read and written synchronously (bounded waits); inside an action a write first waits until an earlier paste of that action has been consumed. Reads work in every phase; writes throw during population. Types: public.utf8-plain-text, public.html, public.rtf.
RichString {format: "rtf" \| "html" \| "markdown"}; .html and .rtf are converted with RtfPipe, Markdig and Seltzer's own HTML→RTF writer (byte-for-byte parity with macOS is not possible).

util.seltzer (Seltzer only; read-only, synchronous; available in every phase except explorerFolder(), which only an action may call):

const w = util.seltzer;               // undefined in PopClip
w?.preferences;                       // { alwaysCopyPlainText, alwaysPastePlainText, copyOnSearch, copyOnOpenLink,
                                      //   spellingShowFirst, otherUrlSchemes }  (a fresh snapshot per read)
w?.installedBrowsers();               // [{ name, identifier, executable, progId, isDefault }]
w?.resolveApp("com.google.Chrome");   // true when the bundle ID / exe / ProgId / AUMID is an installed browser
w?.explorerFolder?.();                // "C:\\Users\\you\\Project", "\\\\server\\share\\dir" or undefined (actions only)

identifier is the mapped bundle ID when Seltzer knows one, else the executable name; use it with openUrl(url, {app}).

explorerFolder() answers the folder of the File Explorer window you used most recently: the Explorer window highest in the z-order (a minimised one counts; hidden windows and windows on other virtual desktops do not), and of a tabbed window its active tab. It returns a drive path (D:\ keeps its backslash, other folders have none) or a UNC path, and undefined when no Explorer window is open, when that tab shows no folder of the file system (This PC, Home, Recycle Bin, Network, search results, a library, the inside of an archive), when that window cannot be read (an older window's folder is never answered instead), or when Explorer does not answer within half a second (a second and a half if Seltzer has not read Explorer's window list yet, right after it starts). Seltzer never activates, navigates or changes the window, and never touches a network path to check it (a local folder must exist). It throws in population functions, regexes and at the module top level, so building the bar never asks Explorer. Privacy: the path tells what you are working on, so an extension learns it only when you click one of its actions — the same as the selected text and popclip.context.browserUrl, which every extension receives — and Seltzer never logs it. The bundled Claude Code extension uses it to start a Claude Code session in that folder; check for the function first (w?.explorerFolder?.()), since older Seltzer versions and PopClip lack it.


Network access


Shell scripts, PowerShell and Command Prompt

All three run as child processes in a Windows Job Object, are killed when the user cancels or when the time limit (default 60 s, Settings → Advanced → Script time limit, 0 = none) expires, and follow the same rules:

Shell scripts (shell script inline, shell script file):

interpreter / #! Runs
python, python3, python3.12 py -3 / py -3.12 (Python launcher) when installed, else a real python.exe/python3.exe on PATH (Microsoft Store stubs are skipped)
pwsh, powershell PowerShell 7 when installed, else Windows PowerShell 5.1
sh, bash, zsh, dash, ksh Git for Windows sh.exe/bash.exe, else a WSL distribution — only when the user turned on Allow sh and bash scripts; zsh/dash/ksh run as bash; WSL gets /mnt/<drive>/… paths
node, ruby, perl, php, … <name>.exe on PATH (Node.js also from Program Files)
/usr/bin/env python3, /usr/bin/env -S python3 -u, /usr/bin/ruby Resolved like the #! line: by program name
osascript, swift, open, say, pbcopy, pbpaste Fail with "… is a macOS program and is not available on Windows"

.bat/.cmd shims are never used as interpreters. Python gets PYTHONUTF8=1 and PYTHONIOENCODING=utf-8.

PowerShell (powershell inline, powershell file): runs pwsh 7 if installed, else Windows PowerShell 5.1, always with -NoProfile -NonInteractive -ExecutionPolicy Bypass -File on a generated wrapper that sets UTF-8 console encodings and then calls your script. Your script gets:

Write the result with [Console]::Out.Write($result) — Write-Output adds a line break (or set trim trailing newline: true). A Group Policy that enforces an execution policy still wins; the action then fails with that message.

Command Prompt (cmd inline, batch file): runs cmd.exe /d /c on a generated .cmd that starts with @echo off and chcp 65001>nul (UTF-8). cmd expands %VAR% before parsing, so %POPCLIP_TEXT% turns the selection into batch syntax (a & calc would start Calculator). Use delayed expansion:

setlocal EnableDelayedExpansion
echo(!POPCLIP_TEXT!

A cmd line holds at most 8,191 characters, so longer selections come out empty; the matched text is also on standard input (findstr "^" copies it). For anything non-trivial, use PowerShell.

Example — one action, two platforms. Because the action type is decided by precedence, a single static action cannot say "shell script on macOS, PowerShell on Windows"; a JavaScript action can pick the script at run time:

// Config.js in WordFrequency.popclipext, next to freq.sh (macOS) and freq.ps1 (Windows)
// #popclip
// name: Word Frequency
// identifier: com.example.wordfreq
// entitlements: [script]
// after: show-result
exports.action = async (input) => {
  const file = util.seltzer ? "freq.ps1" : "freq.sh";      // util.seltzer exists only in Seltzer
  const result = await popclip.runShellScriptFile(file, { stdin: input.text });
  return result.toString();                                 // stdout without the trailing line break
};

runShellScriptFile picks the interpreter from options.interpreter, the file's #! line or its extension (.ps1, .cmd, .bat); runShellScript (inline source) requires options.interpreter.


Key combos: Mac to Windows

keyCombo/keyCombos strings are parsed with PopClip's grammar (order-free, case-insensitive) and translated:

Mac Windows
command, cmd, ⌘ Ctrl
control, ctrl, ^, ⌃ Ctrl
option, opt, ⌥ (Seltzer also accepts alt) Alt
shift, ⇧ Shift
numpad The numeric-keypad key (keys Windows has; numpad = is rejected)
win (Seltzer-only) The Windows key (needs a trusted or confirmed extension)
return, enter Enter (numeric-keypad Enter with numpad)
delete Backspace (the Mac Delete key)
forwarddelete Delete
tab, space, escape/esc, left/right/up/down, home, end, pageup, pagedown, f1–f24 Same keys
help Insert (the key in that position)
clear Clear
A letter That letter key; upper case does not add Shift
Another character (/, ;, é …) The key that types it in the target app's keyboard layout (Shift added when the layout needs it); characters that need AltGr fall back to the US-layout key; a character with no key fails before anything is pressed
Mac key code: hex (0x24), decimal with two or more digits (36), or a plain number in JSON/JavaScript (a single digit is the digit key) The key in that position on an ANSI keyboard (so command 0x09 is Ctrl + the key where V is on a US keyboard); codes of keys Windows lacks (keypad =, Fn) are rejected

keyCombos may contain "wait 100" entries (0–5000 ms). Each combo is sent as one atomic input sequence: modifiers the user is holding are released and restored around it, Seltzer waits for the Windows key to be released first, and Alt/Win presses are masked so they do not open menus or Start. The target is checked before every combo; if another window came to the front, nothing more is pressed.

winKeyCombo/winKeyCombos replace the Mac combos on Windows (PopClip ignores them) and use Windows names: ctrl/control, alt, shift, win/windows, delete/del (= Delete), backspace, insert/ins, apps/menu/contextmenu, printscreen/prtsc, pause, capslock, numlock, scrolllock, pgup/pgdn, volumeup/volumedown/volumemute/mute, 0x.. or vk_XX virtual-key codes, plus the names above:

- title: Redo
  key combo: command shift z     # macOS
  win key combo: ctrl y          # Windows apps usually redo with Ctrl+Y
- title: Line start
  key combo: control a           # would be Ctrl+A (Select All) on Windows ...
  win key combo: home            # ... so say what you mean

In JavaScript, popclip.pressKey(key, modifiers) takes a key name, character or Mac key code and the Mac modifier mask (util.constant.MODIFIER_COMMAND 1048576, MODIFIER_OPTION 524288, MODIFIER_CONTROL 262144, MODIFIER_SHIFT 131072; 2097152 adds numpad); pressKeys([...]) takes combo strings (including wait). util.constant.KEY_* are Mac key codes and are translated by position.


Icons

Specifier On Windows
Text: up to 3 characters or one emoji, with square, circle, search, filled, strike, monospaced, scale=, rotate=, move-x=, move-y=, flip-x, flip-y, preserve-color, preserve-aspect; legacy [[AB]], text AB Same grammar and measurements as PopClip (Segoe UI Variable; Cascadia Mono for monospaced); colour emoji through DirectWrite
Package file (icon.svg, icon.png) SVG (scripts and external references are removed; oversized or deeply nested documents are refused) and PNG up to 4096 px and 8 MB; drawn as a template (tinted with the bar's text colour) unless preserve-color
svg:<svg …>, data:image/svg+xml,…, data:image/png;base64,… Same
iconify:<set>:<name> Downloaded from the Iconify API (api.iconify.design, with fallback hosts), cached for 7 days; shows a placeholder until downloaded, and the title when the user turned off icon downloads. Coloured sets keep their colours
symbol:<name> Apple SF Symbols cannot be used on Windows. 92 names are mapped to Fluent UI System Icons or Lucide (user setting: Close to macOS or Windows (Fluent)); for other names .fill, .circle, .square, .slash and .badge… suffixes are stripped and drawn as the filled style, a circle or square shape or a strike-through; names that still do not match show the title
bundle:<name> Seltzer's own names only: copy, cut, paste, search, link, book, reveal, spelling, folder, plus, loading, warning, chevron-left, chevron-right, more. PopClip's bundle: artwork is not available; other names show the title
Remote http(s) icons Not supported (an off-by-default hidden option in PopClip)

When an icon cannot be drawn, the action's title is shown instead. For icons that work everywhere, ship an SVG in the package or use a text icon.


Windows-specific variables and context

Scripts receive every PopClip variable — POPCLIP_TEXT (matched), POPCLIP_FULL_TEXT, POPCLIP_URLENCODED_TEXT, POPCLIP_HTML, POPCLIP_RAW_HTML, POPCLIP_MARKDOWN (with capture html), POPCLIP_URLS, POPCLIP_EMAILS, POPCLIP_PATHS (one per line), POPCLIP_MODIFIER_FLAGS, POPCLIP_BUNDLE_IDENTIFIER, POPCLIP_APP_NAME, POPCLIP_BROWSER_TITLE, POPCLIP_BROWSER_URL, POPCLIP_EXTENSION_IDENTIFIER, POPCLIP_ACTION_IDENTIFIER, POPCLIP_DECIMAL_SEPARATOR, POPCLIP_GROUPING_SEPARATOR, POPCLIP_OPTION_<ID> — with these Windows meanings and additions:

Variable Value
POPCLIP_MODIFIER_FLAGS Mac bits: Ctrl → 1048576 (command), Alt → 524288 (option), Shift → 131072; control is never set; Win is not reported
SELTZER_MODIFIER_FLAGS Windows bits: 1 Shift, 2 Ctrl, 4 Alt, 8 Win
POPCLIP_BUNDLE_IDENTIFIER The mapped macOS bundle ID for known apps (com.google.Chrome), else the executable name
POPCLIP_APP_EXECUTABLE The executable name, e.g. winword.exe
POPCLIP_BROWSER_URL, POPCLIP_BROWSER_TITLE Resolved on demand (at most 250 ms); empty for private windows unless the user allows it
SELTZER_OMITTED_VARIABLES Comma-separated names of variables left empty because their value exceeded 32,000 characters (Windows' environment limit); read large text from standard input
SELTZER_TEXT_ON_STDIN 1 for PowerShell and cmd children, which receive the matched text on standard input

In JavaScript, popclip.context adds appExecutable, appUserModelId and windowClass, and util.seltzer gives the user's preferences and the installed browsers (above).


Trust, verification and identifiers


Testing with Seltzer.exe run

Seltzer.exe run <file.js|file.ts> [functionName] is the equivalent of PopClip run: it loads the file as a module in a fresh engine — the same globals, bundled libraries, require and TypeScript support as a real extension — calls the named export with no arguments if given, awaits it, prints the result and exits with 0 on success or 1 on a load error, a thrown error or a budget overrun (Error: … is printed). It runs in the invoking process: nothing else of Seltzer starts (no hooks, tray or windows), and it works whether or not Seltzer is running.

// wordcount.js
exports.test = async () => {
  const words = "the quick brown fox".split(/\s+/).length;
  print("counting …");                 // printed immediately
  return `${words} words`;             // printed as the result
};
& "$env:LOCALAPPDATA\SeltzerApp\current\Seltzer.exe" run .\wordcount.js test | Write-Output
$LASTEXITCODE

Seltzer.exe is a Windows (GUI) program: piping its output (as above, or | more in cmd) makes the shell wait for it and keeps the output in order. Ctrl+C cancels a run. In a test:

print() output is always shown. The installed extension of the same name is never touched. (The path above is the installer's layout; use your build or portable folder otherwise.)


Developing with a live folder

  1. Settings → Advanced → Load Unpacked Extension… and pick your *.popclipext folder (or any folder with a Config.* file). It is loaded in place with the origin Developer folder; Seltzer never writes into it or next to it, and never deletes it (uninstalling only forgets it).
  2. Turn on Reload developer extensions on change: about half a second after you save, the extension reloads (its top level runs again). An extension with scripts or entitlements asks for confirmation again after each change — turn on Don't ask before installing unverified extensions while developing if that gets in the way (and off afterwards).
  3. Turn on Extension debug output and open the Debug Console (tray menu or Seltzer.exe console): print(), console.*, script stderr and population errors appear there under Extension.<identifier>, next to load diagnostics.
  4. Why Didn't It Appear? (Seltzer.exe diagnostics) shows whether a selection was captured and "No action applies to this text" when your requirements or regex did not match.
  5. Settings → Actions → ⋯ → Extension → View Source / Export… gives you a .popclipextz of what is installed.

Limits

Limit Value
Selection handled at all 10 × 1,048,576 characters
Regexes and link/e-mail/path detection first 51,200 characters (skipped above)
Snippet from a selection 5,000 characters (files are unlimited)
Package 16 MB unpacked, 1,000 files; LZFSE module 16 MB decoded
Show/preview result one line, 160 characters
Open Link 25 links
wait in key combos 0–5,000 ms
Environment variable 32,000 characters (cmd's %VAR% lines: 8,191)
Script output 16 MB per stream
Action-list folders 5 levels

PopClip is a trademark of Nicholas Moore (Pilotmoon Software). Seltzer is an independent product and is not affiliated with, endorsed by, or sponsored by Pilotmoon Software.