skills/building-multi-ui-apps/SKILL.md
ALWAYS LOAD THIS SKILL WHEN APP HAS BOTH CLI AND GUI, OR MULTIPLE INTERFACES SHARING LOGIC. Do not architect multi-interface apps directly — use this skill first. Multi-interface Python apps: layered architecture for GUI + CLI + API sharing business logic.
npx skillsauth add quick-brown-foxxx/coding_rules_python building-multi-ui-appsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
UI is a plugin. Business logic lives in the domain layer. Adding a new interface (CLI, GUI, API) should not change business logic.
Presentation Layer (top)
├── Qt GUI (PySide6) - consumes domain, handles display
├── CLI (typer) - consumes domain, handles terminal I/O
└── API (FastAPI) - consumes domain, handles HTTP (if needed)
|
v
Domain Layer (middle)
├── Managers - orchestrate operations
├── Models - dataclasses, TypedDicts
└── Services - business rules, pure logic
|
v
Utility Layer (bottom)
├── Helpers - stateless functions
├── Wrappers - typed third-party interfaces
└── Platform - OS-specific implementations
Dependencies flow downward only. Domain never imports from presentation.
Do not use len(sys.argv) > 1 or "any args means CLI" heuristics. They break as soon as the GUI accepts file paths, flags like --debug, or OS-opened document invocations.
Use one executable entry point with a tiny top-level router:
--debug-h / --help, config ..., other explicit CLI words# __main__.py
from __future__ import annotations
import argparse
import sys
from pathlib import Path
GUI_FLAGS = {"--debug"}
CLI_WORDS = {"config"} # Top-level CLI entry words for this app
def parse_gui_request(argv: list[str]) -> tuple[Path | None, bool] | None:
parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
parser.add_argument("path", nargs="?")
parser.add_argument("--debug", action="store_true")
try:
ns, unknown = parser.parse_known_args(argv)
except SystemExit:
return None
if unknown:
return None
path = Path(ns.path).expanduser() if isinstance(ns.path, str) else None
return path, bool(ns.debug)
def wants_cli(argv: list[str]) -> bool:
return bool(argv) and (argv[0] in {"-h", "--help"} or argv[0] in CLI_WORDS)
def main() -> int:
argv = sys.argv[1:]
if wants_cli(argv):
from myapp.bootstrap import create_services
from myapp.cli import build_cli_app
services = create_services(debug=False)
app = build_cli_app(services)
app(args=argv, prog_name="myapp", standalone_mode=False)
return 0
gui_request = parse_gui_request(argv)
if gui_request is not None:
from myapp.gui import run_gui
path, debug = gui_request
return run_gui(path=path, debug=debug)
from myapp.bootstrap import create_services
from myapp.cli import build_cli_app
services = create_services(debug=False)
app = build_cli_app(services)
app(args=argv, prog_name="myapp", standalone_mode=False)
return 0
This gives the intended behavior:
myapp -> GUImyapp file.txt -> GUI with an open-file requestmyapp --debug -> GUI with a debug flagmyapp -h -> Typer helpmyapp config ... -> Typer subcommandThe router stays intentionally small: detect GUI-shaped argv, then hand off to the real presentation layer. Build shared dependencies in the composition root and inject them into run_gui(...) / the Typer app builder instead of wiring objects inside __main__.py.
Your CLI layer should still be normal Typer code:
# cli.py
import typer
def build_cli_app(services: Services) -> typer.Typer:
app = typer.Typer(
add_completion=False,
context_settings={"help_option_names": ["-h", "--help"]},
)
config_app = typer.Typer(add_completion=False)
@config_app.command("get")
def config_get(key: str) -> None:
typer.echo(services.config.read_value(key))
app.add_typer(config_app, name="config")
return app
Both GUI and CLI use the same manager:
# CLI
def cmd_create(name: str) -> int:
result = manager.create_profile(name)
if result.is_err:
print(f"Error: {result.unwrap_err()}", file=sys.stderr)
return 1
print(f"Created: {result.unwrap().name}")
return 0
# GUI
def on_create_clicked(self) -> None:
result = self._manager.create_profile(name)
if result.is_err:
self._show_error(result.unwrap_err())
return
self._refresh_list()
For apps that must run on multiple platforms:
from abc import ABC, abstractmethod
class PlatformBackend(ABC):
@abstractmethod
async def start_instance(self, profile: Profile, binary: Path) -> Result[int, str]: ...
@abstractmethod
def get_data_dir(self) -> Path: ...
@abstractmethod
def get_config_dir(self) -> Path: ...
class LinuxBackend(PlatformBackend):
async def start_instance(self, profile: Profile, binary: Path) -> Result[int, str]:
env = {
"XDG_CONFIG_HOME": str(profile.path / "config"),
"XDG_DATA_HOME": str(profile.path / "data"),
}
process = await asyncio.create_subprocess_exec(
str(binary), "-many", "-workdir", str(profile.path),
env={**os.environ, **env},
)
return Ok(process.pid) if process.pid else Err("Failed to start")
DO NOT DO — platform abstraction layer directly calling platform-specific code with conditionals:
# ❌ WRONG: NotificationsManager directly branches on platform
class NotificationsManager:
def send(self, message: str) -> None:
if sys.platform == "linux":
linux_backend.run(message) # direct call, no interface
elif sys.platform == "darwin":
macos_backend.notify(message) # direct call, no interface
else:
windows_backend.toast(message) # direct call, no interface
The manager now knows about every platform. Adding a new OS means editing business logic. Platform code must be hidden behind an interface/protocol/abstract class; the manager only calls the abstraction.
Select backend at startup:
def get_backend() -> PlatformBackend:
match sys.platform:
case "linux":
return LinuxBackend()
case _:
raise NotImplementedError(f"Unsupported platform: {sys.platform}")
Pass dependencies via constructor parameters. Wire everything in a single composition root function. No DI libraries — they break basedpyright strict or add unnecessary indirection.
# app/bootstrap.py
def create_domain(config: AppConfig) -> SessionManager:
"""Composition root — the ONLY place dependencies are wired."""
db = DatabaseWrapper(config.db_path)
api = ApiClientWrapper(config.api_url, config.api_key)
auth = AuthService(api_client=api)
sync = SyncService(db=db, api_client=api)
return SessionManager(auth=auth, sync=sync)
# GUI entry point
def main_gui() -> None:
config = load_config()
session = create_domain(config)
window = MainWindow(session=session)
...
# CLI entry point
def main_cli() -> None:
config = load_config()
session = create_domain(config)
cli_app = build_typer_app(session=session)
cli_app()
def test_sync_handles_conflict() -> None:
db = FakeDatabaseWrapper()
api = FakeApiClient(responses=[CONFLICT_RESPONSE])
sync = SyncService(db=db, api_client=api)
result = sync.pull_changes()
assert result.is_err
FastAPI can be added as another presentation layer consuming the same domain:
@router.post("/profiles")
async def create_profile(req: CreateProfileRequest) -> ProfileResponse:
result = manager.create_profile(req.name)
if result.is_err:
raise HTTPException(400, result.unwrap_err())
return ProfileResponse.from_domain(result.unwrap())
Other presentation layers also possible in specific cases: TUI, python exportable API
development
ALWAYS LOAD THIS SKILL WHEN A NEW FEATURE, NON-TRIVIAL FIX, REFACTOR, OR PYTHON STRUCTURE CHANGE REQUIRES AN ARCHITECTURE DECISION ABOUT LAYERS, WRAPPERS, COMPOSITION ROOTS, FRAMEWORK CHOICES, REUSABLE CORES, OR WHERE CODE SHOULD LIVE. Do not make Python architecture decisions blindly — use this skill first. Python architecture guide + skill router for boundary placement, reusable core design, composition vs inheritance, framework vs custom choices, backend/service layering, and follow-up docs/skills.
tools
ALWAYS LOAD THIS SKILL WHEN CREATING ANY STANDALONE PYTHON SCRIPT OR SINGLE-FILE AUTOMATION. Do not create Python scripts directly — use this skill first. Single-file Python scripts with PEP 723 inline metadata, uv run, and typer CLI.
development
ALWAYS LOAD THIS SKILL WHEN WRITING OR EDITING PYTHON CODE. Do not write or modify Python files directly — use this skill first. Core Python standards: basedpyright strict typing, Result-based error handling, async patterns, security, code style.
development
ALWAYS LOAD THIS SKILL WHEN WRITING TESTS, ADDING FIXTURES, OR SETTING UP PYTEST. Do not write Python tests directly — use this skill first. Python testing with pytest: philosophy, fixtures, mock servers, containerized testing.