Writing Commands
Commands are Python functions decorated with @skill.command. Arguments are declared with Arg(). Commands can be async or sync, return a dict (auto-wrapped as JSON), and support lifecycle hooks via @skill.setup / @skill.teardown.
Basic command
from cmdop_skill import Arg, Skill
skill = Skill()
@skill.command
async def check(
domain: str = Arg(help="Domain to check", required=True),
timeout: int = Arg("--timeout", help="Timeout in seconds", default=10),
) -> dict:
"""Check SSL certificate for a domain."""
result = await do_check(domain, timeout)
return {"domain": domain, "days_left": result.days_left}
def main() -> None:
skill.run()The Skill() class auto-resolves name and version from the nearest pyproject.toml. The main() function is the CLI entry point declared in pyproject.toml:
[project.scripts]
my-skill = "my_skill._skill:main"Arg() parameters
| Parameter | Description | Example |
|---|---|---|
| First positional | CLI flag name override | Arg("--timeout") |
help | Argument description | Arg(help="Domain to check") |
required | Whether argument is required | Arg(required=True) |
default | Default value | Arg(default=10) |
choices | Allowed values | Arg(choices=["json", "table"]) |
action | Argparse action | Arg(action="store_true") |
nargs | Number of arguments | Arg(nargs="+") |
dest | Attribute name in namespace | Arg(dest="output_format") |
Positional vs optional arguments
Arguments without a default value and without a flag prefix are positional:
@skill.command
async def analyze(
path: str = Arg(help="File path"), # positional
depth: int = Arg("--depth", default=3), # optional
verbose: bool = Arg("-v", action="store_true"), # flag
) -> dict:
...CLI: my-skill analyze ./src --depth 5 -v
Multiple commands
A single skill can have multiple commands:
skill = Skill()
@skill.command
async def check(domain: str = Arg(help="Domain")) -> dict:
"""Check a domain."""
...
@skill.command
async def report(
path: str = Arg(help="Output path"),
format: str = Arg("--format", default="json", choices=["json", "csv"]),
) -> dict:
"""Generate a report."""
...CLI: my-skill check github.com or my-skill report ./out --format csv
Async and sync
Commands can be either async or sync. The framework handles both:
@skill.command
async def fetch(url: str = Arg(help="URL")) -> dict:
"""Async command β use for I/O."""
async with httpx.AsyncClient() as client:
resp = await client.get(url)
return {"status": resp.status_code}
@skill.command
def parse(path: str = Arg(help="File path")) -> dict:
"""Sync command β use for CPU-bound work."""
data = Path(path).read_text()
return {"lines": len(data.splitlines())}Return values
Return a dict β the framework wraps it as {"ok": true, ...}:
@skill.command
async def check(domain: str = Arg(help="Domain")) -> dict:
return {"domain": domain, "valid": True, "days_left": 42}Output:
{"ok": true, "domain": "github.com", "valid": true, "days_left": 42}Raise an exception for errors β the framework returns a structured error:
@skill.command
async def check(domain: str = Arg(help="Domain")) -> dict:
if not domain:
raise ValueError("Domain is required")
...Output:
{"ok": false, "error": "Domain is required"}Lifecycle hooks
Setup runs before any command, teardown after:
skill = Skill()
@skill.setup
async def on_setup():
"""Initialize database connection."""
await init_db()
@skill.teardown
async def on_teardown():
"""Close connections."""
await close_db()
@skill.command
async def query(sql: str = Arg(help="SQL query")) -> dict:
...Both hooks are async. Teardown runs even if the command fails.
Entry point
The main() function dispatches to the correct command based on CLI arguments:
def main() -> None:
skill.run()This is registered in pyproject.toml as:
[project.scripts]
my-skill = "my_skill._skill:main"After pip install -e ., the command my-skill becomes available system-wide.