Metadata-Version: 2.4
Name: docket-cli
Version: 0.2.1
Summary: Deterministic task runner for AI agents — replaces shell scripts and token-burning LLM cron jobs
Project-URL: Homepage, https://github.com/bennybuoy/docket
Project-URL: Documentation, https://github.com/bennybuoy/docket#readme
Project-URL: Repository, https://github.com/bennybuoy/docket
Project-URL: Issues, https://github.com/bennybuoy/docket/issues
Author: Docket Contributors
Keywords: agent,automation,cli,cron,task-runner
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.11
Requires-Dist: click>=8.1
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.1; extra == 'dev'
Provides-Extra: mcp
Requires-Dist: mcp>=1.0; extra == 'mcp'
Description-Content-Type: text/markdown

# Docket

**Deterministic task runner for AI agents and systemd timers.**

[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)
[![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![Tests](https://img.shields.io/badge/tests-321%20passing-brightgreen.svg)](tests/)

Docket gives AI agents and systemd timers a structured, self-documenting entry point for deterministic scheduled tasks — replacing shell scripts and token-burning LLM cron jobs for ops work that doesn't need reasoning. Every task produces structured JSON output, supports dry-run previews, and tracks state in SQLite so agents and schedulers can inspect results without re-running.

## Features

- ✅ **Deterministic task runner** — a clean CLI for ops work that doesn't need LLM reasoning
- ✅ **Structured JSON output** — `--json` flag for machine / agent consumption
- ✅ **Dry-run support** — `--dry-run` on every task, no side effects
- ✅ **Configurable retry with exponential backoff** — per-task `retry` and `backoff_base`
- ✅ **Lock files** — prevent concurrent runs of the same task (`--force` to bypass)
- ✅ **Notification hooks** — `on_success` / `on_failure` / `on_complete` shell commands
- ✅ **systemd timer generation** — `docket install` writes `.service` + `.timer` units
- ✅ **Config editing** — `docket configure` opens config in `$EDITOR` with validation
- ✅ **Health self-check** — `docket doctor` verifies your installation
- ✅ **Drop-in task discovery** — entry points + `~/.docket/tasks/` directory
- ✅ **SQLite-backed state tracking** — last run, exit code, and summary per task
- ✅ **Agent self-discovery** — `docket --json capabilities` describes commands, tasks, inputs, outputs, and safety flags
- ✅ **Async run receipts** — long tasks return a durable `run_id`, logs path, wait/status commands, and polling hint
- ✅ **Command task adapter** — wrap Rust, Go, Bash, Node, or any executable as an agent-visible task

## Installation

```bash
# Install from PyPI
pip install docket-cli

# Install for development
pip install -e ".[dev]"
```

## Quick start

**1. Create a config file:**

```bash
docket configure
```

Or manually create `~/.docket/config.yaml`:

```yaml
tasks:
  backup:
    source: /home/user/data
    remote: remote:backup
    tool: rclone
    schedule: "0 2 * * *"
    retry: 3
    backoff_base: 2.0
    on_success: "notify-send 'Backup succeeded'"
    on_failure: "notify-send 'Backup failed'"
  health-check:
    checks: [disk, memory, systemd-units]
    disk_threshold_pct: 85
    memory_threshold_pct: 90
    systemd_units: [nginx.service, postgresql.service]
```

**2. Run a task:**

```bash
docket --json capabilities    # discover commands and task contracts
docket run hbackup --dry-run  # preview Hermes/OpenClaw backup
docket run hbackup --async    # start hbackup in background
docket run backup --dry-run   # preview
docket run backup             # execute for real
docket run backup --async     # start in background and return a run_id
```

**3. Check status:**

```bash
docket status
docket status backup          # specific task
docket runs backup            # recent run history
docket wait <run_id>          # block until an async run finishes
docket logs <run_id>          # show captured stdout/stderr
docket logs <run_id> --follow # stream captured output until completion
docket events <run_id>        # show structured JSONL events
docket events <run_id> --follow # stream structured events until completion
docket cancel <run_id>        # terminate a running async task
```

## Commands

| Command | Description |
|---------|-------------|
| `docket capabilities` | Show agent-facing command and task metadata |
| `docket list` | List all discovered tasks and their last run status |
| `docket new-task <task>` | Scaffold a drop-in task in `tasks_dir` |
| `docket run <task>` | Execute a task (`--dry-run` to preview, `--force` to bypass guards) |
| `docket run <task> --async` | Start a task in the background and return a durable run ID |
| `docket status [<task>]` | Show last run status for all tasks or a specific task |
| `docket status --run-id <id>` | Show a specific historical run |
| `docket runs [<task>]` | List recent historical runs |
| `docket wait <run_id>` | Wait for a running task to finish |
| `docket logs <run_id>` | Show captured stdout/stderr for an async run |
| `docket logs <run_id> --follow` | Follow captured async output until the run finishes |
| `docket events <run_id>` | Show structured JSONL events recorded for a run |
| `docket events <run_id> --follow` | Follow structured events until the run finishes |
| `docket cancel <run_id>` | Cancel a running async task |
| `docket describe <task>` | Show a task's description and resolved configuration |
| `docket install [<task>]` | Generate systemd timer + service units for scheduled tasks (`--list` to preview) |
| `docket configure` | Open config in `$EDITOR` with YAML validation on save |
| `docket doctor` | Self-diagnostic: checks config, state DB, and task discovery |

All commands support `--json` for structured output and `-v` / `-vv` for logging.

### Agent discovery

Agents should begin with the self-describing contract:

```bash
docket --json capabilities
```

The response includes the Docket version, `agent_contract_version`, configured
state/log/task directories, available commands, and every discovered task with
its description, resolved config, inputs, outputs, schema, required
environment variables, and safety flags:

```json
{
  "tool": "docket",
  "agent_contract_version": 1,
  "commands": [{"name": "run <task> --async", "json": true}],
  "tasks": [
    {
      "name": "hbackup",
      "description": "Back up Hermes/OpenClaw state with hbackup",
      "inputs": {"action": "hbackup action: backup, auto, list, upload, or setup-drive"},
      "outputs": {"archive_path": "Archive path reported by hbackup"},
      "schema": {},
      "required_env": [],
      "network": true,
      "destructive": false
    }
  ]
}
```

Use `docket --json describe <task>` for a focused view before running an
unfamiliar task.

### Agent run receipts

`docket run <task> --async --json` returns immediately with a durable `run_id`,
the child process PID, and the exact status/wait commands an agent can call next.

```json
{
  "ok": true,
  "status": "running",
  "task": "backup",
  "run_id": "20260427T013000000000Z-ab12cd34",
  "pid": 12345,
  "log_path": "/home/user/.docket/logs/20260427T013000000000Z-ab12cd34.log",
  "next_check_after_seconds": 5,
  "status_command": "docket --json status --run-id 20260427T013000000000Z-ab12cd34",
  "wait_command": "docket --json wait 20260427T013000000000Z-ab12cd34",
  "logs_command": "docket logs 20260427T013000000000Z-ab12cd34"
}
```

Completed runs are stored in SQLite history, not just the latest task state, so
agents can inspect past outcomes with `docket runs --json`.
Async child output is captured under `logs_dir`, and agents can read it with
`docket logs <run_id>` without needing to know the filesystem layout. Use
`docket logs <run_id> --follow` for live log tailing, `docket events <run_id>
--json` for parsed JSONL events, and `docket events <run_id> --follow` to watch
structured command-task events as they arrive.

## Configuration

Configuration lives in `~/.docket/config.yaml`. The full schema:

```yaml
# Top-level settings
state_dir: ~/.docket/state.db       # SQLite database for run history
tasks_dir: ~/.docket/tasks          # Drop-in directory for custom task .py files
logs_dir: ~/.docket/logs            # Captured logs for async task runs

# Per-task configuration under "tasks"
tasks:
  backup:
    source: /data/backup            # Local path to back up
    remote: remote:backup           # rclone remote destination
    tool: rclone                    # "rclone", "rsync", or "hbackup"
    extra_args: []                  # Additional CLI args for the backup tool
    idle_timeout_seconds: 1800      # Kill if no output/progress for this many seconds
    schedule: "0 2 * * *"          # Cron schedule (for docket install)
    retry: 3                        # Max retry attempts on failure
    backoff_base: 2.0               # Exponential backoff base (seconds)
    on_success: "notify-send 'Backup done'"      # Shell command(s) on success
    on_failure: "notify-send 'Backup failed'"    # Shell command(s) on failure
    on_complete: "curl -X POST https://example.test/docket"  # Always runs
    notify:
      on: [success, failure]      # Default is [failure] when omitted
      discord:
        webhook_url_env: DISCORD_WEBHOOK_URL
      telegram:
        chat: "8223639759"
        bot_token_env: TELEGRAM_BOT_TOKEN
    docket_bin: docket              # Override docket binary path for systemd units

  health-check:
    checks: [disk, memory, systemd-units]  # Which checks to run
    disk_threshold_pct: 90                  # Fail if disk usage exceeds this %
    memory_threshold_pct: 90                # Fail if memory usage exceeds this %
    systemd_units: []                        # Units that must be active
    schedule: "*/15 * * * *"                # Every 15 minutes
```

### Per-task config keys

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `schedule` | string | — | Cron expression for systemd timer generation |
| `retry` | int | `0` | Max retry attempts when task fails |
| `backoff_base` | float | `2.0` | Base for exponential backoff (wait = `backoff_base ** attempt`) |
| `on_success` | string or list | — | Shell command(s) to run on task success |
| `on_failure` | string or list | — | Shell command(s) to run on task failure |
| `on_complete` | string or list | — | Shell command(s) to run after any terminal result |
| `notify` | mapping | — | Native Discord/Telegram notifications for task completion |
| `docket_bin` | string | `docket` | Path to docket binary for generated systemd units |

All other keys are passed directly to the task's `run()` / `dry_run()` as the `config` dict.

### Config validation

Docket validates shared config before commands run. Top-level `state_dir`,
`tasks_dir`, and `logs_dir` must be strings; `tasks` must be a mapping; task
configs must be mappings.

Shared per-task keys are typed:

- `schedule`, `description`, `docket_bin`: non-empty strings
- `retry`: non-negative integer
- `backoff_base`: positive number
- `on_success`, `on_failure`, `on_complete`: string or list of strings
- `notify`: mapping with `on`, `discord`, and/or `telegram`
- `inputs`, `outputs`: mapping of string keys to string descriptions
- `required_env`: list of strings
- `network`, `destructive`: booleans

Built-in tasks get extra validation:

- `backup.tool`: `rclone`, `rsync`, or `hbackup`
- `backup.source`, `backup.remote`: non-empty strings when present
- `backup.extra_args`: list of strings
- `backup.idle_timeout_seconds`: non-negative number or `null`
- `backup.hbackup_command`: `backup` or `auto`
- `backup.hbackup_bin`, `backup.output`: non-empty strings
- `backup.excludes`: list of strings
- `hbackup.action`: `backup`, `auto`, `list`, `upload`, or `setup-drive`
- `hbackup.hbackup_bin`, `hbackup.output`, `hbackup.archive`, `hbackup.destination`,
  `hbackup.drive_remote`, `hbackup.drive_folder`: non-empty strings
- `hbackup.excludes`, `hbackup.extra_args`: list of strings
- `hbackup.drive`: boolean
- `hbackup.idle_timeout_seconds`: non-negative number or `null`
- `health-check.checks`: list containing `disk`, `memory`, and/or `systemd-units`
- `health-check.*_threshold_pct`: number from `0` to `100`
- `health-check.systemd_units`: list of strings

Unknown keys on custom tasks are allowed so task plugins can define their own
configuration contract.

Command tasks (`type: command`) get extra validation:

- `command`: string or list of strings
- `args`, `dry_run_args`: list of strings
- `dry_run_command`: string or list of strings
- `output_format`: `text`, `json`, `jsonl`, or `auto`
- `success_exit_codes`: list of integers
- `env`: mapping with string keys
- `cwd`: non-empty string
- `timeout_seconds`: positive number

## Writing custom tasks

Create a drop-in task scaffold:

```bash
docket --json new-task fetch-invoices --description "Fetch invoices from the API"
```

Then edit the generated file in `tasks_dir`, or create a Python class extending
`BaseTask` yourself:

```python
from docket.task import BaseTask, TaskResult

class MyTask(BaseTask):
    name = "my-task"
    description = "Does something useful"
    inputs = {"api_url": "API endpoint to fetch"}
    outputs = {"records_fetched": "Number of records fetched"}
    schema = {"api_url": {"type": "string", "required": True}}
    required_env = ["API_TOKEN"]
    network = True
    destructive = False

    def run(self, config: dict) -> TaskResult:
        # ... actual work ...
        return TaskResult(ok=True, summary="Done", details={"records_fetched": 12})

    def dry_run(self, config: dict) -> TaskResult:
        return TaskResult(ok=True, summary="Would do something", dry_run=True)
```

Register via entry point in `pyproject.toml`:

```toml
[project.entry-points."docket.tasks"]
my-task = "my_package.tasks:MyTask"
```

Or drop a `.py` file into `~/.docket/tasks/`.

The metadata fields are optional but strongly recommended for agent use:

| Field | Meaning |
|-------|---------|
| `inputs` | Config keys or external inputs the task expects |
| `outputs` | Machine-readable values the task returns in `TaskResult.details` |
| `schema` | Optional config validation schema checked before execution |
| `required_env` | Environment variables needed at runtime |
| `network` | Whether the task calls external services |
| `destructive` | Whether the task can delete, overwrite, charge money, or mutate important state |

See [docs/writing-tasks.md](docs/writing-tasks.md) for the full guide.

### Command tasks

Use `type: command` when the implementation should live in Rust, Go, Bash,
Node, or any existing executable:

```yaml
tasks:
  fetch-invoices:
    type: command
    description: Fetch invoices with the Rust importer
    command: invoice-fetcher
    args: ["--account", "main", "--jsonl"]
    dry_run_args: ["--account", "main", "--dry-run", "--json"]
    output_format: jsonl
    required_env: [INVOICE_API_TOKEN]
    inputs:
      account: Account slug to fetch
    outputs:
      events: JSONL progress/result events emitted by the command
    network: true
    destructive: false
```

For `output_format: json`, Docket parses stdout into `details.json`.
For `output_format: jsonl`, each stdout line must be a JSON object; Docket
stores parsed events in `details.events`, `details.event_count`, and
`details.last_event`. Raw stdout/stderr are still preserved in the result and,
for async runs, the Docket child output is captured under `logs_dir`.

JSONL events are also promoted into SQLite and can be queried independently:

```bash
docket --json events <run_id>
docket events <run_id> --follow
```

Each event record includes `run_id`, `task_name`, `event_index`, `timestamp`,
`event_type`, and the parsed `event` object. Async command tasks stream JSONL
events into this table while the command is still running.

If `dry_run_args` or `dry_run_command` is omitted, `docket run <task> --dry-run`
returns a safe preview without executing the command.

### Completion hooks

`on_complete` runs after every task result is recorded, regardless of success or
failure. `on_success` and `on_failure` still run only for matching outcomes.
Hooks receive structured environment variables:

| Variable | Meaning |
|----------|---------|
| `DOCKET_RUN_ID` | Durable run ID |
| `DOCKET_TASK_NAME` | Task name |
| `DOCKET_STATUS` | `succeeded`, `failed`, `cancelled`, etc. |
| `DOCKET_EXIT_CODE` | Final Docket exit code |
| `DOCKET_SUMMARY` | One-line result summary |
| `DOCKET_DETAILS_JSON` | JSON-encoded result details |
| `DOCKET_LOG_PATH` | Async log path, when available |

This is the lightweight way to ping an agent, webhook, or monitor when a long
job finishes.

### Native notifications

For common agent channels, use `notify` instead of shelling out to `curl`.
Docket currently supports Discord webhooks and Telegram bot messages:

```yaml
tasks:
  vitals-morning:
    notify:
      on: [success, failure]       # success, failure, or complete
      message: "{task} {status}: {summary} ({run_id})"
      discord:
        webhook_url_env: DISCORD_WEBHOOK_URL
        username: Docket
      telegram:
        chat: "8223639759"
        bot_token_env: TELEGRAM_BOT_TOKEN
```

When `notify.on` is omitted, notifications default to failures only. Prefer
`*_env` settings so secrets stay in the environment instead of YAML. Delivery
results are included in `--json` output under `hooks` as `notify:discord` and
`notify:telegram` entries.

## Built-in tasks

### `hbackup`

First-class integration for [hbackup](https://github.com/ultraworkers/hbackup),
the Hermes/OpenClaw backup and restore CLI.

```yaml
tasks:
  hbackup:
    action: auto                 # backup, auto, list, upload, setup-drive
    hbackup_bin: hbackup
    idle_timeout_seconds: 1800
    schedule: "0 3 * * *"
    retry: 1
    on_complete: "curl -X POST https://example.test/docket-complete"
```

Agent workflow:

```bash
docket --json describe hbackup
docket --json run hbackup --dry-run
docket --json run hbackup --async --force
docket logs <run_id> --follow
docket --json status --run-id <run_id>
```

`action: auto` lets hbackup create and upload using its own config while Docket
provides the agent contract: run IDs, async receipts, logs, retries, history,
cancellation, completion hooks, and task discovery.

### `backup`

Syncs a local directory to a remote destination using **rclone** (default) or
**rsync**. It can still wrap **hbackup** through `tool: hbackup`, but the
preferred Hermes/OpenClaw launch path is the first-class `hbackup` task.

Config keys: `source`, `remote`, `tool` (`rclone`, `rsync`, or `hbackup`),
`extra_args`, `idle_timeout_seconds`, `hbackup_bin`, `hbackup_command`,
`output`, and `excludes`.

```yaml
tasks:
  hbackup:
    action: auto
    schedule: "0 3 * * *"
    retry: 1
    on_complete: "notify-send 'hbackup finished'"
  backup:
    source: /home/user/data
    remote: remote:backup
    tool: rclone
    idle_timeout_seconds: 1800
```

`idle_timeout_seconds` is activity-based, not a hard job duration limit. Large
backups can run for hours as long as `rclone` or `rsync` continues to emit output
or progress. Set it to `0` or `null` to disable idle timeout handling.

Compatibility hbackup backend:

```yaml
tasks:
  hbackup:
    action: auto                   # backup, auto, list, upload, setup-drive
    hbackup_bin: hbackup           # hbackup binary path or command name
    idle_timeout_seconds: 1800
    schedule: "0 3 * * *"
    retry: 1
    on_complete: "curl -X POST https://example.test/docket"

  backup:
    tool: hbackup
    hbackup_command: backup       # backup or auto
    output: ~/backups/openclaw-hermes.tar.zst
    excludes: [target, .git]
```

`docket run backup --dry-run` maps to `hbackup backup --dry-run`.
For new installs, prefer `docket run hbackup`.

### `health-check`

Runs system health checks: disk usage, memory usage, and systemd unit status.

Config keys: `checks` (list of `disk`, `memory`, `systemd-units`), `disk_threshold_pct`, `memory_threshold_pct`, `systemd_units`.

```yaml
tasks:
  health-check:
    checks: [disk, memory, systemd-units]
    disk_threshold_pct: 85
    memory_threshold_pct: 90
    systemd_units: [nginx.service]
```

## Exit codes

| Code | Meaning |
|------|---------|
| `0` | Success |
| `1` | Failure (task error, lock contention, config error) |
| `2` | Dry-run completed (no side effects) |

## Architecture

```
CLI (click)
  │
  ├─► Registry ── discovers ──► BaseTask subclasses
  │     (entry points + ~/.docket/tasks/)
  │
  ├─► Config (YAML loader with defaults)
  │
  ├─► Task.run() / Task.dry_run()
  │     ├─► Retry (exponential backoff)
  │     ├─► LockFile (prevent concurrent runs)
  │     └─► Hooks (on_success / on_failure / on_complete shell commands)
  │
  └─► StateStore (SQLite)
        └─ latest task state + immutable run history
```

**Key modules:**

| Module | Role |
|--------|------|
| `docket.cli` | Click CLI — all commands |
| `docket.task` | `BaseTask` ABC + `TaskResult` dataclass |
| `docket.registry` | Task discovery via entry points and drop-in directory |
| `docket.config` | YAML config loader with path expansion and defaults |
| `docket.state` | SQLite-backed state store (latest state and run history) |
| `docket.lockfile` | Cross-platform lock files to prevent concurrent runs |
| `docket.retry` | Exponential backoff retry logic |
| `docket.hooks` | Notification hooks (`on_success` / `on_failure` / `on_complete`) |
| `docket.systemd` | systemd unit generation and cron↔OnCalendar conversion |
| `docket.logging` | Dual JSON / human-readable log setup |

## Development

```bash
# Install with dev dependencies
pip install -e ".[dev]"

# Run tests
python -m pytest tests/ -q

# Lint
ruff check .

# Build
python -m build
```

## License

[MIT](LICENSE)
