# Example: a CLI command
URL: https://lsd.tools/docs/ex-cli-command
Updated: 2026-07-17

> Decorate a (ctx, ...) function with @tool.command and its signature becomes a subcommand — zero argparse. A parameter with no default is a positional argument, one with a default is an --option, and a bool is a --flag. It runs as `lsd <tool> <name>`.

Every package can add CLI subcommands straight from a typed function — no argparse. Decorate a
`(ctx, ...)` function with [`@tool.command`](/docs/tools-commands-actions) and it runs as
`lsd <tool> <name>`.

## The code

```python
# tool.py
from lsdtools import Tool, Context, Table

tool = Tool("csvkit", label="CSV Kit")


@tool.command("stats", help="print row/column counts and a null report for a CSV")
def stats(ctx: Context, src: str, sort_by: str = "") -> int:
    """`lsd csvkit stats data.csv [--sort-by col]` — quick stats without the desktop."""
    t = Table.read_csv(src)
    if sort_by:
        t = t.sort(sort_by)
        print(f"{len(t)} rows x {len(t.columns)} columns: {t.columns}")
        t.null_report().print()
        return 0
```

## What each line does

- **`@tool.command("stats", help=...)`** — registers a subcommand named `stats`. The `help` string
 shows in `lsd csvkit --help`.
- **`ctx: Context`** — the injected [`Context`](/docs/api-context); it does not become a CLI argument.
 A command may read host services through it (project, ui, engine).
- **`src: str`** — no default, so it becomes a **required positional argument**.
- **`sort_by: str = ""`** — a default, so it becomes an **`--sort-by` option**. (A `bool` parameter
 would become a `--flag`.) The type annotation drives parsing.
- **`print(...)`** — CLI command handlers are one of the few places `print` is the right call (not
 logging). Return an **`int`** exit code.

## Run it

From a shell, once the package is installed or added to your project:

```bash
lsd csvkit stats people.csv
# → 3 rows x 2 columns: ['name', 'age']
# column nulls pct
# name 0 0
# age 0 0

lsd csvkit stats people.csv --sort-by age
# → same stats, rows sorted by age first
```

`@tool.command` returns the function unchanged, so you can also call it directly in a test with a
stub context:

```python
from lsdtools import Context

stats(Context.testing(), "people.csv")
# → prints the stats; returns 0
```

## Try changing it

- Add `limit: int = 20` (an `--limit` option) and print `t.head(limit)`.
- Add a `to_parquet: bool = False` flag that writes `src` out as Parquet when set.
- Add a second command (`@tool.command("head", ...)`) — each typed function is its own subcommand.

## Related

**Learn** — [Commands & actions](/docs/tools-commands-actions)

**API** — [Tool](/docs/api-tool) · [Context](/docs/api-context) · [CLI commands](/docs/api-cli)

**Examples** — [A dockable panel](/docs/ex-panel) · [A complete package](/docs/ex-full-package)
