Appearance
Building Your First CLI
This guide walks through the smallest useful Klix application and then expands it into something you could actually ship.
If you have not installed the framework yet, start with Installation. If you want a quicker tour first, read Quickstart.
What You Are Building
The goal is a CLI that:
- starts with a prompt
- registers a couple of slash commands
- keeps a typed session state object
- prints styled output through the shared UI namespace
Klix is designed so you spend most of your time in three places:
- the
Appdefinition - command handlers
- session-aware helpers
See App, Commands, and Session for the architectural background.
Step 1: Create the Smallest App
python
from dataclasses import dataclass
import klix
@dataclass
class DemoState(klix.SessionState):
visits: int = 0
app = klix.App(
name="DemoCLI",
version="0.1.0",
description="A small Klix example",
state_schema=DemoState,
)
@app.on("start")
def on_start(session: klix.Session) -> None:
session.ui.print("DemoCLI is ready.", color="accent")
@app.command("/hello", help="Say hello")
def hello(session: klix.Session) -> None:
session.state.visits += 1
session.ui.print(f"Hello. Visit #{session.state.visits}", color="success")
if __name__ == "__main__":
app.run()Run it:
bash
python main.pyThen type:
text
/hello
/exit/exit is handled by the app loop itself. You do not need to register it unless you want custom behavior before the process exits.
Step 2: Add Arguments
Klix commands can validate arguments through a Pydantic model.
python
from pydantic import BaseModel, Field
class DeployArgs(BaseModel):
environment: str = Field(description="Target environment")
force: bool = False
@app.command("/deploy", args_schema=DeployArgs, help="Deploy the service")
def deploy(args: DeployArgs, session: klix.Session) -> None:
mode = "forced" if args.force else "safe"
session.ui.print(
f"Deploying to {args.environment} in {mode} mode",
color="warning",
)Example input:
text
/deploy production --forceInternally, Klix tokenizes the input, maps positionals to schema fields in declaration order, and then lets Pydantic coerce and validate values. That flow is described in Router.
Step 3: Add Better Startup Output
Most apps want some startup text and maybe a basic layout.
python
@app.on("start")
def on_start(session: klix.Session) -> None:
session.ui.clear()
session.ui.layout.header.set("DemoCLI", color="accent")
session.ui.layout.status.set("Ready", "Use /help", color="muted")
session.ui.layout.redraw_ui()
session.ui.print("Try /hello or /deploy staging", color="muted")Klix layout regions are intentionally small and coarse. They work well for headers, status lines, and lightweight dashboards. Read Layout for the tradeoffs.
Step 4: Add One Middleware
Middleware is a good place for cross-cutting behavior such as logging or command timing.
python
@app.middleware
async def log_commands(ctx: klix.MiddlewareContext, next: klix.NextFn) -> None:
ctx.session.ui.print(f"[log] {ctx.command.name}", color="muted")
await next(ctx)Because Klix parses before middleware runs, ctx.command is already available here. The full flow is documented in Middleware.
Step 5: Persist State Across Runs
If you want session state to survive between launches:
python
app = klix.App(
name="DemoCLI",
version="0.1.0",
description="A small Klix example",
state_schema=DemoState,
persist_session=True,
)State is stored under:
text
~/.klix/sessions/<app-name>/<session-id>.jsonKlix only loads a saved session when the user provides a session_id through app config or your own startup wiring. Persistence details are covered in State and Persistence.
A More Realistic Example
python
from dataclasses import dataclass, field
from pydantic import BaseModel
import klix
@dataclass
class OpsState(klix.SessionState):
current_env: str = "staging"
deploys: list[str] = field(default_factory=list)
class DeployArgs(BaseModel):
environment: str
version: str
force: bool = False
app = klix.App(
name="OpsTool",
version="1.0.0",
description="Operations helper",
state_schema=OpsState,
)
@app.on("start")
def on_start(session: klix.Session) -> None:
session.ui.layout.header.set("OpsTool", color="accent")
session.ui.layout.status.set(
left=f"env: {session.state.current_env}",
right="ready",
color="muted",
)
session.ui.layout.redraw_ui()
session.ui.print("Run /deploy <env> <version>", color="muted")
@app.middleware
async def sync_status(ctx: klix.MiddlewareContext, next: klix.NextFn) -> None:
ctx.session.ui.layout.status.set(
left=f"env: {ctx.session.state.current_env}",
right=f"running {ctx.command.name}",
color="muted",
)
ctx.session.ui.layout.redraw_ui()
await next(ctx)
ctx.session.ui.layout.status.set(
left=f"env: {ctx.session.state.current_env}",
right="ready",
color="muted",
)
ctx.session.ui.layout.redraw_ui()
@app.command("/deploy", args_schema=DeployArgs, help="Deploy a version")
def deploy(args: DeployArgs, session: klix.Session) -> None:
session.state.current_env = args.environment
session.state.deploys.append(f"{args.environment}:{args.version}")
session.ui.output.panel(
f"Deploying {args.version} to {args.environment}",
title="Deploy",
border_color="warning",
)
if args.force:
session.ui.print("Force mode enabled", color="warning")Common Mistakes
- Assuming normal text is routed to a handler. In the default app loop, only slash commands are routed. Free-form text requires a custom loop. See Examples: Gemini-Style CLI.
- Expecting shell-style quoting. The router uses a simple whitespace split. Complex quoting rules are not implemented. See Router.
- Putting long-running work directly in a sync handler. If the work can block, make the handler async or spawn a background task from the session.
- Forgetting to redraw layout regions after changing them.
header.set(...)andstatus.set(...)update state;redraw_ui()makes the change visible.