Skip to Content

Writing Commands

TL;DR

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

ParameterDescriptionExample
First positionalCLI flag name overrideArg("--timeout")
helpArgument descriptionArg(help="Domain to check")
requiredWhether argument is requiredArg(required=True)
defaultDefault valueArg(default=10)
choicesAllowed valuesArg(choices=["json", "table"])
actionArgparse actionArg(action="store_true")
nargsNumber of argumentsArg(nargs="+")
destAttribute name in namespaceArg(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.

Last updated on