The CLI
pyxle is the command-line entry point users actually type. It's a
Typer application with five commands:
| Command | Purpose |
|---|---|
pyxle init <name> |
Scaffold a new Pyxle project |
pyxle dev [path] |
Run the development server |
pyxle build [path] |
Compile and bundle for production |
pyxle serve [path] |
Serve a production build |
pyxle check [path] |
Validate the project without serving |
This doc explains what each command does, how flags and configuration flow from user input to the rest of the framework, and the design choices behind the CLI surface.
Files:
cli/__init__.py(~1220 lines) — the Typer commandscli/init.py(~115 lines) —pyxle initscaffoldingcli/scaffold.py(~100 lines) — copies template filescli/templates.py(~50 lines) — template registrycli/logger.py(~190 lines) —ConsoleLoggerwith structured outputconfig.py(~500 lines) —pyxle.config.jsonschema and validation
Configuration precedence
Every Pyxle command reads configuration in the same order, from lowest to highest priority:
- Built-in defaults — Hard-coded in
PyxleConfigfield defaults (config.py:44). pyxle.config.json— JSON file at the project root.- Environment variables — Variables starting with
PYXLE_override the corresponding config field. - CLI flags — Anything you pass on the command line wins.
Higher priority overrides lower priority. So:
PYXLE_STARLETTE_PORT=9000 pyxle dev --port 8001…ends up running on port 8001 (CLI beats env beats file beats default).
The mechanism is PyxleConfig.apply_overrides() (config.py:104),
which returns a new frozen instance with selective field updates.
Each layer applies overrides on top of the previous one. The CLI
layer runs last:
file_config = load_config(project_root / "pyxle.config.json") # 1+2
env_config = file_config.apply_env_overrides() # 3
final_config = env_config.apply_overrides( # 4
starlette_host=cli_host, # only override if non-None
starlette_port=cli_port,
debug=cli_debug,
)apply_overrides() ignores None values, so passing --port 8001
overrides while not passing --port leaves the lower layer alone.
This is the standard "explicit wins, implicit yields" pattern.
Source: cli/__init__.py:267-340.
pyxle init <name>
Scaffolds a new Pyxle project from a template.
$ pyxle init my-app
✅ Created my-app/
ℹ️ Installing Python dependencies...
ℹ️ Installing Node dependencies...
✅ Done. Run `cd my-app && pyxle dev` to start.Under the hood:
- Validates the name. Must be a non-empty string that's safe to
use as a directory name. The directory must not exist (unless
--forceis passed). - Resolves the template. Defaults to
default. Templates live inpyxle/templates/scaffold/inside the package — they're shipped withpyxle-framework. - Copies the template files into the new directory using
shutil.copytree. The template includes:pages/index.pyxl— a working "Hello, Pyxle" pagepages/api/pulse.py— a sample API routepackage.json— Vite + React 18 + Tailwind dependenciespyxle.config.json— minimal config ({"middleware": []})requirements.txt— pinnedpyxle-frameworkversionpostcss.config.cjs,tailwind.config.cjs— Tailwind setuppublic/— favicon and a few static files.gitignore— sensible defaults
- Optionally installs dependencies. With
--install(the default), runspip install -e .andnpm installin the new directory. With--no-install, skips both — useful for CI or when you want to inspect the scaffold before installing.
Source: cli/__init__.py:116-211, cli/init.py,
cli/scaffold.py.
Why does the scaffold ship with the framework?
A few alternatives we considered:
- Download from a Git repository. Requires the user to have
network access and
gitinstalled. Doesn't pin a version. - Download from a CDN. Requires the user to have network access. Versions can drift.
- Generate from a template engine. Adds a runtime dependency on Jinja2 or similar. Templates become harder to read and modify.
Shipping the scaffold as part of the Pyxle package means:
- It works offline.
- It's pinned to the framework version automatically.
- You can read it directly:
pip show pyxle-frameworkto find the install path, then look atpyxle/templates/scaffold/. - You can fork it locally without affecting the upstream package.
The downside is that updating the scaffold requires a Pyxle release, not a separate publish step. We think the trade-off is worth it for beta-stage software where the install story matters more than ergonomics.
pyxle dev [path]
Runs the development server. The full lifecycle is documented in The dev server, but the CLI-specific bits are:
Flags
| Flag | Default | Effect |
|---|---|---|
--host |
127.0.0.1 |
Starlette bind host |
--port |
8000 |
Starlette bind port |
--vite-host |
127.0.0.1 |
Vite bind host |
--vite-port |
5173 |
Vite bind port |
--debug / --no-debug |
--debug |
Dev mode toggle |
--config <path> |
pyxle.config.json |
Config file path |
--ssr-workers <n> |
from config | Override SSR worker count |
--tailwind / --no-tailwind |
auto | Force/disable Tailwind watcher |
What it does
- Resolves the project root (CLI argument or current directory).
- Loads
pyxle.config.json, applies env vars, applies CLI flags. - Resolves global stylesheets and scripts via the styling helpers.
- Builds a
DevServerSettingsfrom the merged config. - Creates a
DevServerinstance and callsasyncio.run(server.start()).
The actual heavy lifting happens inside DevServer.start() —
spawning Vite, starting the SSR worker pool, building the Starlette
app, starting the file watcher, running uvicorn. See
The dev server.
Source: cli/__init__.py:247-398.
Tailwind handling
Pyxle has optional first-class Tailwind support. The CLI auto-detects
Tailwind by checking for tailwind.config.cjs or tailwind.config.js
in the project root. If found:
- A separate Tailwind watcher process is spawned alongside Vite.
- The watcher rebuilds the Tailwind CSS file whenever a Tailwind source class changes.
- When
postcss.config.cjsalso exists, the standalone Tailwind watcher is skipped — Vite handles Tailwind via PostCSS instead, which is faster and integrates with HMR.
The flag --tailwind / --no-tailwind lets you force one mode or the
other. Most users never touch it. Source:
cli/__init__.py:343-378, devserver/tailwind.py.
pyxle build [path]
Compiles and bundles the project for production. Detailed walkthrough in Build and serve.
Flags
| Flag | Default | Effect |
|---|---|---|
--config <path> |
pyxle.config.json |
Config file path |
--incremental |
false (full rebuild) |
Skip unchanged files |
--dist-dir <path> |
./dist |
Output directory |
What it does
- Loads config, applies env, applies CLI flags.
- Builds a
DevServerSettings(yes, the same dataclasspyxle devuses —pyxle buildreuses 100% of the dev server's settings structure). - Lazily imports
pyxle.build.pipeline.run_build(lazy because importing the build pipeline pulls invite.py,manifest.py, etc., and we wantpyxle initandpyxle checkto start fast). - Calls
run_build(settings, dist_dir=...). - Prints a summary: pages, API routes, client assets.
Source: cli/__init__.py:400-515.
The lazy import pattern
You'll notice cli/__init__.py uses lazy imports for the heavy
modules:
def _resolve_run_build():
from pyxle.build.pipeline import run_build
return run_buildThis is intentional. pyxle init should not have to load the build
pipeline. pyxle check should not have to load Starlette. By
deferring imports until they're actually needed, the CLI startup
stays fast — typically under 100ms for pyxle --help or pyxle check, even on cold imports.
This pattern is documented in pyxle/CLAUDE.md rule 16 (Lazy
imports for heavy modules).
pyxle serve [path]
Serves a production build. See Build and serve for the full walkthrough.
Flags
| Flag | Default | Effect |
|---|---|---|
--host |
127.0.0.1 |
Starlette bind host |
--port |
8000 |
Starlette bind port |
--config <path> |
pyxle.config.json |
Config file path |
--dist-dir <path> |
./dist |
Where to read the build from |
--skip-build / --no-skip-build |
false |
Skip the implicit pyxle build |
--serve-static / --no-serve-static |
true |
Serve dist/client/ and dist/public/ |
--ssr-workers <n> |
from config | Override SSR worker count |
What it does
- Loads config, applies env, applies CLI flags.
- Forces
debug=Falsein the resolved config. This is the single most important production override. - Optionally runs
pyxle buildfirst (the default — set--skip-buildto use existing artifacts). - Loads
dist/page-manifest.jsonto populate the route registry. - Builds the Starlette app via
create_starlette_app()(same factory aspyxle dev). - Spawns uvicorn to serve the app.
Source: cli/__init__.py:517-733.
--skip-build is for CI
In a typical CI pipeline:
- run: pyxle build # produces dist/
- run: docker build -t myapp . # bakes dist/ into the imageThen, on the production server:
pyxle serve --skip-build --host 0.0.0.0 --port 8000--skip-build tells Pyxle "the artifacts are already in dist/,
don't rebuild them." This separates "compile time" from "run time"
cleanly. In dev, you usually omit the flag and let pyxle serve
build for you.
pyxle check [path]
Validates the project without starting a server. This is the linter / pre-commit hook command.
$ pyxle check
ℹ️ Checked 28 .pyxl file(s) in my-app/
error: [python] line 1: invalid syntax
--> pages/_errors_python_syntax/bad-keyword.pyxl
error: [python] line 2: @server loader must accept a `request` argument
--> pages/_errors_python_validation/missing-request.pyxl
error: [jsx] line 1: JSX syntax error: Unexpected token (4:17)
--> pages/_errors_jsx_syntax/invalid-expression.pyxl
...
❌ Check failed with 14 error(s)What it checks
- The project structure exists.
pages/must be a directory. - The config is valid.
pyxle.config.json(if present) must parse and pass schema validation. - Node.js dependencies are present.
node_modules/should exist (warning if missing). - Every
.pyxlfile parses cleanly. Both Python and JSX halves.
Tolerant mode
The fourth check is the interesting one. pyxle check runs the
parser in tolerant mode:
result = parser.parse(pyxl_file, tolerant=True, validate_jsx=True)
for diag in result.diagnostics:
diagnostics.append(...)Source: cli/__init__.py:780-820.
Tolerant mode means the parser collects every diagnostic in every file in a single pass instead of stopping at the first error. This is the right behaviour for a linter — you want to see all your errors at once, not one error at a time.
validate_jsx=True adds Babel-backed JSX validation on top, so
JSX syntax errors (unclosed tags, mismatched braces, invalid
expressions) are also caught. This is the only Pyxle command that
runs Babel validation by default; pyxle dev and pyxle build
skip it because Vite catches JSX errors at bundle time.
Defensive per-file wrapping
Each per-file parse is wrapped in a try/except:
try:
result = parser.parse(pyxl_file, tolerant=True, validate_jsx=True)
except Exception as exc:
diagnostics.append((rel_path, f"[python] parser crashed: {type(exc).__name__}: {exc}"))
continueThis is defense-in-depth. Tolerant mode shouldn't raise — it collects errors instead — but a future parser bug could throw an unexpected exception. The defensive wrap catches it, reports it as a structured diagnostic, and continues scanning the rest of the project.
This was added during the parser audit on 2026-04-08 after a
pathological fixture (200-level nested expression) was found to
crash CPython's parser stack and abort the entire pyxle check
run mid-scan. With the wrap, a single broken file is reported and
the scan continues.
Source: cli/__init__.py:790-820. Audit details:
manual-tests/AUDIT.md, "Bug 3".
Cascade suppression
pyxle check runs both the Python parser AND Babel for every file.
When the Python parser finds a [python] error, the broken Python
content sometimes ends up in the jsx_code segment (because the
walker can't classify what isn't valid Python), and Babel then also
fails on it — producing a noisy [jsx] error.
The parser handles this internally with cascade suppression:
when any [python] diagnostic is collected, the JSX validation is
skipped for that file. Source: compiler/parser.py:1080-1100.
Result: each file with a Python syntax error reports just the
Python error, not both.
Exit codes
| Exit code | Meaning |
|---|---|
0 |
All checks passed, no errors |
1 |
One or more errors found |
2 |
Project structure invalid (no pages/, etc.) |
You can use pyxle check in CI as a pre-merge gate, or as a
pre-commit hook to catch errors before they hit git.
The ConsoleLogger
All CLI output (info, warning, error, success) goes through
ConsoleLogger (cli/logger.py). It supports two output modes:
- Human (
--log-format console, default) — colored emoji-prefixed output:ℹ️,✅,⚠️,❌,▶️. Tested with both light and dark terminals. - Machine (
--log-format json) — newline-delimited JSON records. Useful for piping into log aggregators or other tooling.
The logger also supports --verbose / -v and --quiet / -q to
adjust verbosity. -q suppresses everything below warnings; -v
shows debug-level info.
The diagnostic() method is special — it formats parser
diagnostics with file path and line number in a consistent way:
error: [python] line 5: @server loader must be declared as async
--> pages/sync-loader.pyxlThis is the format pyxle check uses, but it's available to any
caller that wants it.
Source: cli/logger.py:1-192.
How a CLI invocation flows
Putting everything together, here's what happens when you type
pyxle dev --port 8001 my-app/:
1. Typer parses the command-line:
- command: "dev"
- directory: "my-app/"
- port: 8001 (override, not None)
2. CLI handler runs:
- Resolve project_root = "my-app/" → absolute path
- Load pyxle.config.json from project_root
- Apply env overrides (PYXLE_*)
- Apply CLI overrides (port=8001, host=None, debug=None, ...)
- Resolve global styles/scripts
- Build DevServerSettings.from_project_root(...)
3. Create the dev server:
- DevServer(settings, logger, ...)
4. Run it:
- asyncio.run(server.start())
- Lifecycle steps from dev-server.md
- uvicorn serves until Ctrl+CThe CLI's job is mostly input parsing and config resolution.
Once it has a DevServerSettings, it hands off to the dev server
and gets out of the way. The same pattern applies to every command:
parse → resolve → build settings → call into the relevant subsystem.
This is why cli/__init__.py is 1220 lines but most of those
lines are flag declarations and validation, not logic. The actual
work happens elsewhere.
Why Typer?
We picked Typer for the CLI surface because:
- Type hints become flags automatically. A function parameter
port: int = 8000becomes--port 8000with type validation. Less boilerplate thanargparse, less magic than Click's@click.option. - It supports Rich rendering for help output and error messages, which makes the help text readable.
- It plays nicely with Python type checkers. mypy, pyright, and IDEs all understand the function signatures.
- It has good docs and a small API. Easy to onboard.
The downside is that Typer doesn't expose every Click feature directly — sometimes you have to drop into Click for advanced behaviour. We've only had to do this once, for a custom shell completion handler.
Where to read next
The dev server — What
pyxle devactually starts up after the CLI hands off control.Build and serve — What
pyxle buildandpyxle serveactually do.The parser — How
pyxle checkuses tolerant mode to surface every error in every file in one pass.