A desktop GUI for running Python scripts: a sidebar of your scripts, a form generated from a manifest, an isolated virtual environment per script, and live output streaming.
Built with Tauri v2 + Rust + Preact. Instead of a system Python it bundles
uv as a sidecar binary, which downloads the
interpreter each script asks for — so Python does not need to be installed.
macOS only, for now — arm64 and x86_64, shipped as one universal build.
That is also the only platform anything is built or tested on: bundle.targets
in tauri.conf.json is ["app", "dmg"], and both CI and the release workflow
run on macOS runners alone.
- Windows — the
#[cfg(windows)]code inrunner/exists but is compiled by nothing and has never been executed. Its blocker is written up insrc-tauri/build.rs: the test binary cannot load, becauserfdneeds a ComCtl32 v6 manifest that only the app binary gets. Re-enabling it is a scoped piece of work, not a flag. - Linux — out of scope (see
AGENTS.md).
Download the .dmg from the
Releases page.
Builds are not code-signed, so macOS will warn you the first time. This is expected — signing requires a paid Apple Developer ID. Every later launch opens normally.
Open the .dmg and drag PyShell into Applications. Then, on the first
launch only:
- Open the Applications folder in Finder.
- Right-click PyShell → Open (double-clicking will not offer the option — the dialog only has Move to Trash).
- Confirm Open in the dialog.
If macOS still refuses with "PyShell is damaged and can't be opened" — that is Gatekeeper reacting to the quarantine flag on an unsigned download, not a real corruption — clear the flag:
xattr -dr com.apple.quarantine /Applications/PyShell.appAlternatively, launch it once, then open System Settings → Privacy & Security and press Open Anyway next to the PyShell notice.
The window opens with an empty sidebar. To get from there to a running script:
- Add a script. Press + File for a single
.py, or + Folder for a project containing apyshell.yaml(⌘O / ⇧⌘O). The script stays where it is — PyShell only stores a reference, never a copy. Nothing to hand? Grab one from mono-ninja/PyShell-scripts. - Prepare the environment. Press Prepare Env in the header. On the very
first run this also downloads a Python interpreter, so expect it to take a
minute and to need a network connection. If the script has a
requirements.txt, PyShell lists the packages and asks before installing. - Fill in the form and press Run (⌘↩). Output streams into the Output tab as it happens.
Two things worth knowing straight away: ⌘W hides the window instead of quitting (a running script keeps going; click the Dock icon to return), and PyShell → Show Logs in Finder opens the rotated application log if something goes wrong.
- Node.js 18+
- Rust 1.77+
- Tauri CLI 2 (installed via npm)
- macOS: Xcode Command Line Tools
- Windows: Microsoft Visual C++ Build Tools
# 1. Install npm dependencies
npm install
# 2. Download the uv sidecar for your platform
# (not in git — ~40 MB per target)
npm run fetch-uv
# 3. Start the app in dev mode
npx tauri devWithout the sidecar not even cargo check succeeds: tauri_build copies every
externalBin inside build.rs and fails with
resource path "binaries/uv-<triple>" doesn't exist. To build for another
target, pass it explicitly: npm run fetch-uv -- x86_64-pc-windows-msvc.
Run cargo test first on a fresh clone: it generates src/types/bindings/
(ts-rs), without which tsc fails with confusing errors in app.tsx.
# Rust tests + ts-rs binding generation
cargo test --manifest-path src-tauri/Cargo.toml
# Rust typecheck
cargo check --manifest-path src-tauri/Cargo.toml
# TypeScript typecheck
npx tsc --noEmit
# Frontend tests
npm test
# Full bundle
npx tauri buildImport a local script with + Folder (a project with pyshell.yaml) or
+ File (a single .py) in the sidebar — or install one from the community
repo with + Store (also in File ▸ Add from Store…): pick a destination
folder once, press Install on a script, and PyShell downloads it there and
imports it exactly like a local folder. The destination is remembered for next
time, the catalog is cached for the session (Refresh re-fetches it; GitHub
allows 60 API requests per hour without a token, and a catalog fetch spends
one), and installed scripts are badged — a script whose folder was deleted
shows a Missing folder warning instead, pointing at the relink flow in the
sidebar.
When the repo carries a newer version than an installed script, the store
offers Update instead of the badge — and a small dot on the + Store
button says so without opening it. The update replaces the script's folder with
the repo's current contents (the old folder is kept as a <name>.backup-…
sibling — only the newest backup survives, older ones are pruned — so nothing
is lost), while presets, history and secrets — keyed by
script id — carry over. The environment flips to Stale and rebuilds if the
dependencies changed. Updates are blocked while the script is running.
Installed rows also carry Repair (re-download the folder over the existing files, without any version comparison — for installs that went bad) and Uninstall (the sidebar's Remove, without leaving the dialog: removes the script, its environment, presets, history and secrets; the folder itself stays on disk). Both confirm before acting.
The interface ships in English and Ukrainian — Settings ▸ Appearance switches the language (the choice persists; the system locale is the default).
The catalog is checked against GitHub when the app starts and at most once every five minutes after that (a check costs 2 of the 60 unauthenticated API requests per hour); Refresh always re-checks immediately. File downloads are pinned to the repo's latest commit, so a fresh push is visible without waiting for GitHub's CDN cache.
Three ways to describe a script's parameters, in priority order:
pyshell.yaml— a YAML manifest beside the script- PEP 723 — an inline TOML block in the
.pyfile's comments - Introspection — automatic analysis of
argparse/click/typer(requires a prepared environment)
With no manifest a minimal schema is generated; you can run introspection later for a better one.
You can put a page for whoever runs the script (what the fields mean, what to
expect) beside it and PyShell shows it in the Docs panel (⌘D). For a project
folder that is pyshell.md, for a single file <script-name>.md (e.g.
report-demo.md next to report-demo.py), because several scripts can share a
directory. With neither, README.md is shown. Files are looked up in a
docs/ subfolder first, and only if it is absent or empty in the script's
own directory. Translations use a suffix: pyshell_ua.md alongside pyshell.md
adds a language picker to the panel. A CommonMark subset is rendered; HTML is
never executed and links open in the system browser.
Press Prepare Env in the header. PyShell:
- Creates an isolated venv with
uv venvat the requested Python version - If a
requirements.txtexists, lists the packages and asks for confirmation - Installs them with
uv pip install -r requirements.txt
Each script gets its own venv. Changing the dependencies rebuilds it automatically.
Fill in the form and press Run. Output streams live:
- Output — stdout/stderr with filtering, search and autoscroll
- Results — structured events (progress, table, chart, markdown) and artifacts
- History — recent runs with Retry, run comparison, and CSV/JSON export
Run stays disabled while the form is invalid (required, min/max, pattern).
A secret field is stored in the macOS Keychain / Windows Credential Manager.
The value never returns to the frontend and is never written to state. It is
passed to the process through an environment variable, never through argv.
Settings ▸ Secrets lists every stored secret across all scripts (script, key,
date) with a per-entry delete; removing a script deletes its secrets along
with it.
Save a set of parameters as a preset (type a name → Save). Presets load with one click.
If the schema was guessed (Guessed badge):
- Prepare the environment first (Prepare Env)
- Press Introspect in the banner
- Confirm in the consent dialog — this executes arbitrary code from the script
- Press Save Manifest to store the result as
pyshell.yaml
Right-click a script → Pin to Favorites, the star that appears on hover, or ⇧⌘F for the current one. Pinned scripts move to their own section at the top of the sidebar and get the numbers ⌘1…⌘9, in the order you added them. Sorting A→Z does not disturb that order: the position is the shortcut, so a number must not change on its own. The same entries, with script names, appear in the Favorites menu. A pinned script disappears from its category below — it is never shown twice.
Every shortcut lives in the native menu (src-tauri/src/menu.rs) rather than in
the webview — otherwise macOS simply would not deliver it to the app. That is
also why the system ones work: ⌘C/⌘V/⌘X/⌘A/⌘Z in form fields, ⌘Q to quit, ⌘W to
hide the window (the run and the form survive; clicking the Dock icon brings it
back).
| Keys | Action |
|---|---|
| ⌘O / ⇧⌘O | Import script / folder |
| ⌘, | Settings |
| ⌘F | Search the script list |
| ↑ / ↓ | Move through the script list |
| ⌘1…⌘9 | Favorite script #1…#9 |
| ⇧⌘F | Pin / unpin the current script |
| ⇧⌘1…⇧⌘4 | Parameters / Output / Results / History |
| ⌘D | Docs panel |
| ⌘↩ | Run script |
| ⌘. | Cancel run |
| ⇧⌘P | Show the command line |
| ⌘M / ⌘W / ⌘Q | Minimise / hide / quit |
| Esc | Close a dialog or context menu |
On Windows and Linux, ⌘ means Ctrl.
schema: 1
id: com.example.my-script # optional; otherwise local.<hash>
name: My Script
description: Description
icon: 🔧
category: Tools
needs: # other PyShell scripts this one expects, by id
- com.pyshell.sitecrawler
runtime:
entry: main.py # relative to pyshell.yaml
python: ">=3.11,<3.14"
requirements: requirements.txt # optional
timeout: 60 # seconds; optional
inputs:
- key: url
type: url
label: URL
required: true
binding:
kind: arg
flag: "--url"
style: space
- key: count
type: int
label: Count
default: 1
min: 1
max: 100
binding:
kind: arg
flag: "--count"
style: space
- key: verbose
type: bool
default: false
binding:
kind: arg
flag: "--verbose"
style: flag
- key: tags
type: multi_choice
options:
- value: a
- value: b
binding:
kind: arg
flag: "--tag"
style: repeat
outputs:
artifacts:
- "*.csv"
result: noneThe full guide — every field type, bindings, conditional visibility, structured events, artifacts — is built into the app: Help → How to Write a Script.
A script can expect other PyShell scripts to be installed — declare their
manifest ids in needs: (see the example above). The header then warns with a
Needs: … pill while any of them is missing, the sidebar marks scripts with
dependencies (the marker turns amber while one is missing), the Store installs
the missing ones alongside automatically, and at run time every installed
dependency's folder is passed as a JSON object in the PYSHELL_DEPS env var
(json.loads(os.environ["PYSHELL_DEPS"]) → {"com.pyshell.sitecrawler": "/abs/folder"}). needs is an expectation, not a lock — a missing dependency
never blocks the run, it is just absent from the map. The full guide covers the
four dependency kinds (data, subprocess, import, optional synergy).
| Type | Description | Binding |
|---|---|---|
string |
Text | arg / env / positional / stdin / temp_file |
multiline |
Multi-line text | temp_file recommended |
int |
Integer with min/max | any |
float |
Number with min/max | any |
bool |
Yes/no | arg with style: flag |
choice |
Dropdown | any |
multi_choice |
Multi-select | arg with style: repeat or joined |
file / files / dir |
File / folder picker | any |
save_path |
Save location | any |
secret |
Password (keychain) | env only |
date |
Date | any |
url |
URL | any |
| Style | Example |
|---|---|
space |
--url https://example.com |
equals |
--url=https://example.com |
flag |
--verbose (only when true) |
repeat |
--tag a --tag b |
joined |
--tags a,b (with sep: ",") |
- key: mode
type: choice
options: [{value: simple}, {value: advanced}]
default: simple
binding: {kind: arg, flag: "--mode", style: space}
- key: advanced_option
type: string
visible_if:
op: eq
key: mode
value: advanced
binding: {kind: arg, flag: "--advanced", style: space}Operators: eq, ne, truthy.
A script can print JSON to stderr to render progress, tables, markdown, charts
and a status line. The JSON must contain "pyshell": true — otherwise the
line is treated as an ordinary log entry, so scripts that log {"error": ...}
to stderr do not vanish:
import json, sys
def emit(event):
event["pyshell"] = True
print(json.dumps(event), file=sys.stderr, flush=True) # flush is required
# Progress bar (pct is 0..100)
emit({"type": "progress", "pct": 50, "message": "Processing..."})
# Table
emit({"type": "table", "columns": ["URL", "Status"], "rows": [["https://x.com", "OK"]]})
# Markdown result (a CommonMark subset; HTML is not executed)
emit({"type": "markdown", "content": "## Done\n\nFound **3** entries."})
# Chart (line | bar)
emit({
"type": "chart",
"chart_type": "line",
"title": "Response time",
"labels": ["/", "/api"],
"series": [{"name": "p50", "values": [12, 45]}],
})
# Status
emit({"type": "status", "message": "Done"})stderr lines that are not JSON, or lack "pyshell": true, appear as ordinary log
output.
progress, table, markdown, chart and status replace the previous
value rather than appending. Events are not batched and count toward the same
output cap as log lines (50 MB / 500k lines per run), so throttle them — one per
whole percent is plenty. Do not use rich/tqdm for progress: PyShell uses
pipes, not a PTY.
A collection of scripts for PyShell lives in a separate repository: mono-ninja/PyShell-scripts.
Each ships a manifest, so it works straight after import. The easiest way is
+ Store in the sidebar: browse the catalog, press Install, and the
folder is downloaded into a destination you choose and imported in one step.
The manual route still works too — download the folder (or a single .py) and
add it with Import Script… (⌘O) or Import Folder…. PyShell installs
the dependencies into an isolated venv itself — just press Prepare Env.
| What | Where |
|---|---|
| Script list | ~/Library/Application Support/com.pyshell.app/scripts.json |
| Favorites (⌘1…⌘9) | ~/Library/Application Support/com.pyshell.app/favorites.json |
| Schemas | ~/Library/Application Support/com.pyshell.app/schemas/ |
| Virtual environments | ~/Library/Application Support/com.pyshell.app/envs/ |
| Run output | ~/Library/Application Support/com.pyshell.app/output/ |
| State (values, presets, history) | ~/Library/Application Support/com.pyshell.app/state/ |
| Logs | ~/Library/Application Support/com.pyshell.app/logs/ |
| Settings (language, retention) | ~/Library/Application Support/com.pyshell.app/settings.json |
| Secrets | macOS Keychain / Windows Credential Manager |
| Secret index (names only, never values) | ~/Library/Application Support/com.pyshell.app/secrets.json |
| Bookmarks | ~/Library/Application Support/com.pyshell.app/bookmarks/ |
Run artifacts go to PYSHELL_OUTPUT_DIR (passed to the script as an environment
variable), not next to the script. The last 50 runs per script are kept
(configurable in Settings ▸ History & limits, which governs the History list
and the run folders with one number).
- Secrets — in the keychain, passed via env, never via argv
- Introspection — executes arbitrary code, so it is gated behind a consent
dialog; 10s timeout, env without secrets,
PYSHELL_INTROSPECT=1 - App Sandbox is off — scripts have full access to the system; only import scripts you trust
- Dependencies —
uv pip install -rwith sdist fallback for Python 3.13 - Script Store — downloads files from the community repo over HTTPS but never executes anything: installing only parses the manifest, and running is the same explicit step as for a local script. Repo paths are validated (no traversal, ASCII names only) and per-install size caps apply; files land in a staging directory and are moved into place only when the download completes.
CI (.github/workflows/ci.yml) runs the checks above on every push to main
and every PR — macOS only. The Windows job was removed after a failing tag
build; the cause is now understood (see Platforms) but not fixed, so it stays
out.
A release is built from a tag:
# bump the version in package.json, src-tauri/Cargo.toml and tauri.conf.json together
git tag v0.5.1 && git push origin v0.5.1The workflow builds a universal .dmg for macOS (arm64 + x86_64 in one file),
then creates a draft release — the artifacts have to be checked and
published by hand. No Windows or Linux artifacts are produced (see Platforms).
Before building it necessarily runs npm run fetch-uv (sidecar + uv licenses)
and cargo test (generates the ts-rs bindings); without those two steps the
build fails before bundling.
Builds are unsigned — see Installing and first launch
for what users will see. Auto-update (tauri-plugin-updater) is deliberately
disabled: the plugin is not registered in lib.rs, and the "active": false
field in tauri.conf.json is a Tauri v1 leftover with no effect.
PyShell is released under the MIT license — see LICENSE.
Copyright (c) 2026 Dmytro Lobov
The app bundles the third-party uv binary
(Astral Software Inc., MIT OR Apache-2.0) as a sidecar. Its licenses are
downloaded by npm run fetch-uv into src-tauri/licenses/uv/ and ship inside
the bundle. The full list of third-party code and MPL-2.0 dependencies is in
THIRD-PARTY-LICENSES.md.
The Python interpreters uv downloads at runtime are distributed by their
authors (python-build-standalone / PSF) under their own terms and are not part of
PyShell.