Skill: jettydiot/jettyd (version 1.1.2)

--- name: jettyd description: Interact with IoT devices via the jettyd platform — read sensors, send commands, manage rules, and list devices version: 1.1.2 tags: - iot - esp32 - sensors - automation - mqtt metadata: author: jettyd license: MIT homepage: https://jettyd.com api_base: https://api.jettyd.com/v1 openclaw: requires: env: - JETTYD_API_KEY config: - path: skills.entries.jettyd.apiKey description: API key for jettyd platform (alternative to JETTYD_API_KEY env var) bins: - node network: - host: api.jettyd.com description: jettyd IoT platform API fileAccess: - path: ~/.openclaw/openclaw.json access: read description: Reads API key from OpenClaw config if JETTYD_API_KEY env var is not set --- # jettyd IoT Skill Interact with IoT devices via the jettyd platform. Read sensor data, send commands, manage rules, and list devices. ## When to use Use this skill when the user asks about: - Device status, online/offline, last seen - Sensor readings: temperature, humidity, distance, voltage, current - Controlling actuators: relay, LED, PWM - Pushing JettyScript rules (threshold alerts, automations) - Webhook subscriptions for device events - Anything involving their ESP32 or jettyd devices ## Config Reads API key from `~/.openclaw/openclaw.json`: ```json { "skills": { "entries": { "jettyd": { "apiKey": "tk_xxxx", "baseUrl": "https://api.jettyd.com/v1" } } } } ``` Or from env: `JETTYD_API_KEY` ## CLI All operations go through `scripts/jettyd-cli.js`. ```bash node skills/jettyd/scripts/jettyd-cli.js <command> [args] ``` ## Commands | Command | Description | |---------|-------------| | `list` | List all devices with status | | `device <id>` | Device detail + all sensor readings | | `telemetry <id> [metric] [period]` | Historical readings (1h/6h/24h/7d) | | `command <id> <action> [params]` | Send command to device | | `push_config <id> <json_or_file>` | Push JettyScript rules | | `webhooks` | List webhook subscriptions | | `create_webhook <name> <url> <events...>` | Create webhook | ## Example prompts **"What devices do I have?"** → `node .../jettyd-cli.js list` **"What's the temperature on my greenhouse sensor?"** → `node .../jettyd-cli.js list` to find device ID, then `device <id>` **"Turn on the relay on device abc123"** → `node .../jettyd-cli.js command abc123 relay.on` **"Blink the LED 3 times"** → `node .../jettyd-cli.js command abc123 led.blink '{"count":3,"interval_ms":300}'` **"Alert me if temperature goes above 30°C"** → Compose JettyScript JSON and run `push_config <id> <json>` **"Show me temperature history for the last 24 hours"** → `node .../jettyd-cli.js telemetry abc123 temperature 24h` ## push_rules — JettyScript rule format Use `push_config <id> <json>` to deploy rules to a device. Rules run on the jettyd cloud and fire webhooks / alerts when conditions are met. **Temperature alert example** — alert when temperature exceeds 30 °C: ```json { "rules": [ { "id": "temp-high-alert", "trigger": { "type": "threshold", "metric": "temperature", "operator": ">", "value": 30 }, "cooldown_seconds": 300, "actions": [ { "type": "alert", "severity": "warning", "message": "Temperature is {{temperature}}°C — above threshold of 30°C" } ] } ] } ``` Push it: ```bash node skills/jettyd/scripts/jettyd-cli.js push_config <device-id> '{"rules":[...]}' # or from a file: node skills/jettyd/scripts/jettyd-cli.js push_config <device-id> ./rules.json ``` Supported trigger operators: `>` `<` `>=` `<=` `==` `!=` Supported action types: `alert` `webhook` `command`

path: blueprint.md
content: # jettyd + OpenClaw Blueprint

**The complete setup for AI agents that talk to physical hardware.**

This blueprint documents how to connect OpenClaw (or any AI agent stack) to real IoT devices using [jettyd](https://jettyd.com) as the middleware layer.

---

## What this gives you

After following this blueprint, you can ask your AI agent:

> *"What's the temperature in the greenhouse?"*  
> *"Turn on the irrigation relay for 30 seconds."*  
> *"Alert me if humidity drops below 40%."*

And it will work — because your ESP32 is speaking directly to your AI agent.

---

## Architecture

```
[ESP32 device]
    │  jettyd firmware (MIT open source)
    │  - Self-registers on first boot
    │  - Publishes telemetry via MQTT
    │  - Receives commands
    │  - Runs JettyScript rules on-device
    ▼
[jettyd platform]  ←→  [OpenClaw agent]
  api.jettyd.com           MCP server or REST
  mqtt.jettyd.com          skill or LangChain tool
```

---

## Part 1 — Hardware setup (15 minutes)

### What you need
- ESP32-S3, C3, or C6 dev board (~$5)
- USB cable for flashing
- ESP-IDF 5.x installed

### Steps

```bash
# 1. Clone the template
git clone https://github.com/jettydiot/jettyd-firmware-template my-device
cd my-device

# 2. Fetch the SDK
make setup

# 3. Configure — edit sdkconfig.defaults:
#    CONFIG_JETTYD_FLEET_TOKEN="your-fleet-token-here"
#    CONFIG_JETTYD_WIFI_SSID="YourNetworkName"
#    CONFIG_JETTYD_WIFI_PASSWORD="YourNetworkPassword"

# 4. Build and flash
rm -f sdkconfig
idf.py build flash monitor
```

On first boot you'll see:
```
I (13600) jettyd:   Jettyd running
I (13600) jettyd:   Drivers: 0
```

Your device is now provisioned. ✓

---

## Part 2 — Platform account (5 minutes)

1. Sign up at [jettyd.com](https://jettyd.com) → free tier (5 devices)
2. Get your API key from `api.jettyd.com/v1/api-keys`
3. Create a fleet token at `api.jettyd.com/v1/fleet-tokens`
4. Put the fleet token in `sdkconfig.defaults` (step 1 above)

**Test it:**
```bash
curl https://api.jettyd.com/v1/devices \
  -H "[REDACTED_SECRET]"
# → [{"id": "...", "name": "device-abc", "status": "online"}]
```

---

## Part 3 — Connect to OpenClaw (5 minutes)

### Option A: MCP server (Claude Desktop / Cursor)

Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "jettyd": {
      "command": "npx",
      "args": ["@jettyd/mcp"],
      "env": {
        "JETTYD_API_KEY": "your-api-key-here"
      }
    }
  }
}
```

Restart Claude Desktop. Now ask: *"What jettyd devices do I have?"*

### Option B: OpenClaw skill

The jettyd skill is installed at `~/.openclaw/workspace/skills/jettyd/`.

Configure in `~/.openclaw/openclaw.json`:
```json
{
  "skills": {
    "entries": {
      "jettyd": {
        "apiKey": "your-api-key-here",
        "baseUrl": "https://api.jettyd.com/v1"
      }
    }
  }
}
```

Use the CLI directly:
```bash
node ~/.openclaw/workspace/skills/jettyd/scripts/jettyd-cli.js list
node ~/.openclaw/workspace/skills/jettyd/scripts/jettyd-cli.js device DEVICE_ID
node ~/.openclaw/workspace/skills/jettyd/scripts/jettyd-cli.js command DEVICE_ID relay.on
```

### Option C: LangChain tool

See `examples/langchain_tool.py` in this directory for a complete LangChain integration.

```python
from jettyd_tool import JettydTool
tools = [JettydTool([REDACTED_SECRET]")]
agent = initialize_agent(tools, llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION)
agent.run("What is the temperature on my greenhouse sensor?")
```

---

## Part 4 — Add sensors (10 minutes)

Edit `jettyd-sdk/jettyd/src/driver_registry.c`:

```c
#include "dht22.h"

void jettyd_register_drivers(void)
{
    // DHT22 temperature + humidity on GPIO 4
    dht22_config_t air = { .pin = 4 };
    dht22_register("air", &air);
}
```

Rebuild and flash. Readings appear in the device shadow within 60 seconds.

**Available drivers:** DHT22, DS18B20, BME280, HC-SR04, INA219, soil moisture, relay, LED, button, PWM

---

## Part 5 — Automations with JettyScript (5 minutes)

Push rules to your device over the air — no reflashing:

```bash
curl -X PUT https://api.jettyd.com/v1/devices/DEVICE_ID/config \
  -H "[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "rules": [{
      "id": "temp-alert",
      "when": {
        "type": "threshold",
        "sensor": "air.temperature",
        "op": ">",
        "value": 30,
        "debounce": 30
      },
      "then": [{
        "action": "publish_alert",
        "params": {"severity": "warning", "message": "Too hot!"}
      }]
    }]
  }'
```

Subscribe a webhook to forward alerts to Slack, email, or anywhere:
```bash
curl -X POST https://api.jettyd.com/v1/webhooks \
  -H "[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Slack alerts",
    "url": "https://hooks.slack.com/...",
    "events": ["device.alert.warning", "device.alert.critical"]
  }'
```

---

## Part 6 — Dashboard

`app.jettyd.com` — login with your account to see device status, live readings, and send commands from the browser.

---

## Available tools summary

| Interface | Best for |
|-----------|----------|
| MCP server (`@jettyd/mcp`) | Claude Desktop, Cursor, Continue |
| OpenClaw skill | OpenClaw agents (this setup) |
| LangChain tool | Python agent stacks |
| REST API | Any HTTP client |
| Dashboard | Browser / visual overview |

---

## Resources

- Firmware SDK: [github.com/jettydiot/jettyd-firmware](https://github.com/jettydiot/jettyd-firmware) (MIT)
- Template: [github.com/jettydiot/jettyd-firmware-template](https://github.com/jettydiot/jettyd-firmware-template)
- MCP server: [github.com/jettydiot/jettyd-mcp](https://github.com/jettydiot/jettyd-mcp) / `npx @jettyd/mcp`
- Docs: [docs.jettyd.com](https://docs.jettyd.com)
- Waitlist: [jettyd.com](https://jettyd.com)

---

*Built in Johannesburg 🦞 — the IoT dock for AI agents.*


path: examples/README.md
content: # jettyd skill — examples

Runnable examples showing how to integrate the jettyd IoT platform.

| File | What it shows |
|------|--------------|
| `langchain_tool.py` | Wrap the jettyd REST API as LangChain tools and drive them with a GPT-4o agent |

## Quick start

See the `langchain_tool.py` file header for dependency requirements.

```bash
export OPENAI_API_KEY=sk-...
python skills/jettyd/examples/langchain_tool.py
```

The API key is read automatically from `~/.openclaw/openclaw.json`
(`skills.entries.jettyd.apiKey`) or from the `JETTYD_API_KEY` environment variable.


path: examples/langchain_tool.py
content: """
jettyd LangChain tool — wrap the jettyd REST API as LangChain tools.

Usage:
    pip install langchain langchain-openai requests

    export OPENAI_API_KEY=sk-...
    python langchain_tool.py
"""

import json
import os
from pathlib import Path
from typing import Optional

import requests
from langchain.agents import AgentExecutor, create_openai_functions_agent
from langchain.tools import BaseTool
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder


# ---------------------------------------------------------------------------
# Config helpers
# ---------------------------------------------------------------------------

def _load_api_key() -> str:
    """Load jettyd API key from openclaw.json or JETTYD_API_KEY env var."""
    env_key = os.environ.get("JETTYD_API_KEY")
    if env_key:
        return env_key

    config_path = Path.home() / ".openclaw" / "openclaw.json"
    if config_path.exists():
        config = json.loads(config_path.read_text())
        key = (
            config.get("skills", {})
            .get("entries", {})
            .get("jettyd", {})
            .get("apiKey")
        )
        if key:
            return key

    raise RuntimeError(
        "No jettyd API key found. Set JETTYD_API_KEY or add it to "
        "~/.openclaw/openclaw.json under skills.entries.jettyd.apiKey"
    )


BASE_URL = "https://api.jettyd.com/v1"
DEMO_DEVICE_ID = "930c4077-fbe7-48f2-824c-c9e806175f58"


def _headers() -> dict:
    return {"Authorization": f"Bearer {_load_api_key()}"}


# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------

class ListDevicesTool(BaseTool):
    name: str = "list_devices"
    description: str = (
        "List all jettyd IoT devices with their online/offline status and last-seen time. "
        "Returns a JSON list of device objects."
    )

    def _run(self, query: str = "") -> str:
        resp = requests.get(f"{BASE_URL}/devices", headers=_headers(), timeout=10)
        resp.raise_for_status()
        devices = resp.json()
        summary = [
            {
                "id": d.get("id"),
                "name": d.get("name"),
                "online": d.get("online", False),
                "lastSeen": d.get("lastSeen"),
            }
            for d in devices
        ]
        return json.dumps(summary, indent=2)


class ReadDeviceShadowTool(BaseTool):
    name: str = "read_device_shadow"
    description: str = (
        "Read the latest sensor readings (shadow) for a jettyd device. "
        "Input: a device ID string. "
        "Returns JSON with all current sensor values (temperature, humidity, voltage, etc.)."
    )

    def _run(self, device_id: str) -> str:
        device_id = device_id.strip()
        resp = requests.get(
            f"{BASE_URL}/devices/{device_id}/shadow",
            headers=_headers(),
            timeout=10,
        )
        resp.raise_for_status()
        return json.dumps(resp.json(), indent=2)


class SendCommandTool(BaseTool):
    name: str = "send_command"
    description: str = (
        "Send a command to a jettyd device. "
        'Input: JSON string with keys "device_id" (string), "action" (string), '
        'and optional "params" (object). '
        "Common actions: relay.on, relay.off, led.on, led.off, led.blink, led.toggle. "
        'Example: {"device_id": "abc123", "action": "relay.on", "params": {"duration": 5000}}'
    )

    def _run(self, input_json: str) -> str:
        try:
            data = json.loads(input_json)
        except json.JSONDecodeError as exc:
            return f"Invalid JSON input: {exc}"

        device_id = data.get("device_id", "").strip()
        if not device_id:
            return "Missing device_id"

        payload = {"action": data["action"]}
        if "params" in data:
            payload["params"] = data["params"]

        resp = requests.post(
            f"{BASE_URL}/devices/{device_id}/commands",
            headers={**_headers(), "Content-Type": "application/json"},
            json=payload,
            timeout=10,
        )
        resp.raise_for_status()
        return json.dumps(resp.json(), indent=2)


# ---------------------------------------------------------------------------
# Agent
# ---------------------------------------------------------------------------

def build_agent(verbose: bool = True) -> AgentExecutor:
    tools = [ListDevicesTool(), ReadDeviceShadowTool(), SendCommandTool()]

    llm = ChatOpenAI(model="gpt-4o", temperature=0)

    prompt = ChatPromptTemplate.from_messages(
        [
            (
                "system",
                "You are a helpful IoT assistant with access to jettyd device tools. "
                "Use them to answer questions about devices and sensor readings.",
            ),
            ("human", "{input}"),
            MessagesPlaceholder(variable_name="agent_scratchpad"),
        ]
    )

    agent = create_openai_functions_agent(llm, tools, prompt)
    return AgentExecutor(agent=agent, tools=tools, verbose=verbose)


# ---------------------------------------------------------------------------
# Demo
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    agent_executor = build_agent()

    questions = [
        "What devices do I have and which are online?",
        f"What is the current temperature on device {DEMO_DEVICE_ID}?",
        f"Turn on the LED on device {DEMO_DEVICE_ID}.",
    ]

    for question in questions:
        print(f"\n>>> {question}")
        result = agent_executor.invoke({"input": question})
        print(result["output"])


path: references/api-summary.md
content: # jettyd API Reference

Base URL: `https://api.jettyd.com/v1`  
Auth: `Authorization: Bearer <api_key>`

## Devices

| Method | Path | Description |
|--------|------|-------------|
| GET | `/devices` | List all devices |
| GET | `/devices/:id` | Device detail |
| GET | `/devices/:id/shadow` | Latest sensor readings |
| GET | `/devices/:id/telemetry?metric=&from=&limit=` | Historical telemetry |
| POST | `/devices/:id/commands` | Send command `{action, params}` |
| PUT | `/devices/:id/config` | Push JettyScript rules config |
| GET | `/devices/:id/commands` | List recent commands |

## Device Types & Fleet

| Method | Path | Description |
|--------|------|-------------|
| GET | `/device-types` | List device types |
| POST | `/device-types` | Create device type |
| GET | `/fleet-tokens` | List fleet tokens |
| POST | `/fleet-tokens` | Create fleet token |

## Webhooks

| Method | Path | Description |
|--------|------|-------------|
| GET | `/webhooks` | List webhooks |
| POST | `/webhooks` | Create webhook `{name, url, events, secret}` |
| PUT | `/webhooks/:id` | Update webhook |
| DELETE | `/webhooks/:id` | Delete webhook |
| POST | `/webhooks/:id/test` | Send test event |

## Command actions (built-in)

| Action | Params | Description |
|--------|--------|-------------|
| `relay.on` | `{duration?: ms}` | Turn relay on |
| `relay.off` | — | Turn relay off |
| `led.on` | — | Turn LED on |
| `led.off` | — | Turn LED off |
| `led.blink` | `{count, interval_ms}` | Blink LED |
| `led.toggle` | — | Toggle LED |

## Webhook event types

`device.online` `device.offline` `device.alert.info` `device.alert.warning` `device.alert.critical` `device.config_loaded` `device.config_rejected` `command.sent` `command.acked` `command.failed` `telemetry.received`


path: scripts/jettyd-cli.js
content: #!/usr/bin/env node
/**
 * jettyd CLI — OpenClaw skill for jettyd IoT platform
 * Usage: node jettyd-cli.js <command> [args...]
 */

import { readFileSync, existsSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';

// ── Config ────────────────────────────────────────────────────────────────────

function getConfig() {
  let [REDACTED_SECRET];
  let baseUrl = process.env.JETTYD_BASE_URL || 'https://api.jettyd.com/v1';

  if (!apiKey) {
    const cfgPath = join(homedir(), '.openclaw', 'openclaw.json');
    if (existsSync(cfgPath)) {
      try {
        const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
        const entry = cfg?.skills?.entries?.jettyd;
        if (entry?.apiKey) [REDACTED_SECRET];
        if (entry?.baseUrl) baseUrl = entry.baseUrl;
      } catch {}
    }
  }

  if (!apiKey) {
    console.error('❌ No API key found. Set JETTYD_API_KEY or add to ~/.openclaw/openclaw.json');
    process.exit(1);
  }

  return { apiKey, baseUrl };
}

// ── HTTP ──────────────────────────────────────────────────────────────────────

async function api(method, path, body) {
  const { apiKey, baseUrl } = getConfig();
  const res = await fetch(`${baseUrl}${path}`, {
    method,
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    body: body ? JSON.stringify(body) : undefined,
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`API ${res.status}: ${text}`);
  }

  return res.json();
}

// ── Formatting ────────────────────────────────────────────────────────────────

function statusDot(status) {
  return status === 'online' ? '●' : status === 'provisioning' ? '◐' : '○';
}

function relativeTime(iso) {
  if (!iso) return 'never';
  const diff = Date.now() - new Date(iso).getTime();
  const s = Math.floor(diff / 1000);
  if (s < 60) return `${s}s ago`;
  if (s < 3600) return `${Math.floor(s / 60)}m ago`;
  if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
  return `${Math.floor(s / 86400)}d ago`;
}

function formatShadow(shadow) {
  if (!shadow || Object.keys(shadow).length === 0) return '  (no readings yet)';
  const reported = shadow.reported || shadow;
  return Object.entries(reported)
    .filter(([k]) => !k.startsWith('_') && k !== 'mac_address')
    .map(([k, v]) => {
      const val = typeof v === 'number' ? v.toFixed(2) : v;
      return `  ${k.padEnd(20)} ${val}`;
    })
    .join('\n');
}

// ── Commands ──────────────────────────────────────────────────────────────────

async function cmdList() {
  const devices = await api('GET', '/devices');
  if (!devices.length) {
    console.log('No devices found.');
    return;
  }
  console.log(`\n${'Status'.padEnd(8)} ${'Name'.padEnd(24)} ${'Device ID'.padEnd(38)} ${'Last seen'}`);
  console.log('─'.repeat(90));
  for (const d of devices) {
    const dot = statusDot(d.status);
    console.log(`${(dot + ' ' + d.status).padEnd(8)} ${d.name.padEnd(24)} ${d.id.padEnd(38)} ${relativeTime(d.last_seen_at)}`);
  }
  console.log(`\n${devices.length} device(s) total`);
}

async function cmdDevice(id) {
  const d = await api('GET', `/devices/${id}`);
  const shadow = await api('GET', `/devices/${id}/shadow`).catch(() => ({}));

  console.log(`\n━━━ ${d.name} ━━━`);
  console.log(`  ID:            ${d.id}`);
  console.log(`  Status:        ${statusDot(d.status)} ${d.status}`);
  console.log(`  Type:          ${d.device_type_id || 'unknown'}`);
  console.log(`  Firmware:      ${d.firmware_version || 'unknown'}`);
  console.log(`  Last seen:     ${relativeTime(d.last_seen_at)}`);
  console.log(`\n  Sensor readings:`);
  console.log(formatShadow(shadow));
}

async function cmdTelemetry(id, metric, period = '24h') {
  const periodMap = { '1h': 1, '6h': 6, '24h': 24, '7d': 168 };
  const hours = periodMap[period] || 24;
  const from = new Date(Date.now() - hours * 3600 * 1000).toISOString();

  const params = new URLSearchParams({ from, limit: 100 });
  if (metric) params.set('metric', metric);

  const data = await api('GET', `/devices/${id}/telemetry?${params}`);

  if (!data.length) {
    console.log(`No telemetry for device ${id}${metric ? ` (${metric})` : ''} in the last ${period}.`);
    return;
  }

  // Group by metric name
  const grouped = {};
  for (const row of data) {
    for (const [k, v] of Object.entries(row.readings || {})) {
      if (!grouped[k]) grouped[k] = [];
      grouped[k].push(v);
    }
  }

  console.log(`\nTelemetry — ${id} — last ${period} (${data.length} readings)\n`);
  for (const [name, vals] of Object.entries(grouped)) {
    if (metric && name !== metric) continue;
    const nums = vals.filter(v => typeof v === 'number');
    if (!nums.length) continue;
    const min = Math.min(...nums).toFixed(2);
    const max = Math.max(...nums).toFixed(2);
    const avg = (nums.reduce((a, b) => a + b, 0) / nums.length).toFixed(2);
    const latest = nums[nums.length - 1].toFixed(2);
    console.log(`  ${name.padEnd(20)} latest: ${latest.padStart(8)}  avg: ${avg.padStart(8)}  min: ${min.padStart(8)}  max: ${max.padStart(8)}`);
  }
}

async function cmdCommand(id, action, paramsStr) {
  let params = {};
  if (paramsStr) {
    try { params = JSON.parse(paramsStr); }
    catch { console.error('❌ Invalid params JSON'); process.exit(1); }
  }

  const result = await api('POST', `/devices/${id}/commands`, { action, params });
  console.log(`✅ Command sent — ID: ${result.id || 'ok'}`);
  if (result.status) console.log(`   Status: ${result.status}`);
}

async function cmdPushConfig(id, configArg) {
  let config;
  try {
    // Try as file path first, then inline JSON
    if (existsSync(configArg)) {
      config = JSON.parse(readFileSync(configArg, 'utf8'));
    } else {
      config = JSON.parse(configArg);
    }
  } catch {
    console.error('❌ Invalid config — provide a JSON file path or inline JSON string');
    process.exit(1);
  }

  await api('PUT', `/devices/${id}/config`, config);
  console.log(`✅ Config pushed to device ${id}`);
  console.log(`   ${config.rules?.length || 0} rules, ${config.heartbeats?.length || 0} heartbeats`);
}

async function cmdWebhooks() {
  const hooks = await api('GET', '/webhooks');
  if (!hooks.length) { console.log('No webhooks configured.'); return; }
  console.log(`\n${'Name'.padEnd(24)} ${'Events'.padEnd(30)} URL`);
  console.log('─'.repeat(90));
  for (const h of hooks) {
    const events = (h.events || []).join(', ');
    console.log(`${h.name.padEnd(24)} ${events.padEnd(30)} ${h.url}`);
  }
}

async function cmdCreateWebhook(name, url, ...events) {
  const hook = await api('POST', '/webhooks', { name, url, events });
  console.log(`✅ Webhook created — ID: ${hook.id}`);
  console.log(`   Events: ${events.join(', ')}`);
}

// ── Main ──────────────────────────────────────────────────────────────────────

const [,, cmd, ...args] = process.argv;

const commands = {
  list:           () => cmdList(),
  device:         () => cmdDevice(args[0]),
  telemetry:      () => cmdTelemetry(args[0], args[1], args[2]),
  command:        () => cmdCommand(args[0], args[1], args[2]),
  push_config:    () => cmdPushConfig(args[0], args[1]),
  webhooks:       () => cmdWebhooks(),
  create_webhook: () => cmdCreateWebhook(...args),
};

if (!cmd || !commands[cmd]) {
  console.log(`jettyd CLI\n\nUsage: jettyd-cli.js <command> [args]\n\nCommands:\n${Object.keys(commands).map(c => `  ${c}`).join('\n')}`);
  process.exit(1);
}

commands[cmd]().catch(e => { console.error(`❌ ${e.message}`); process.exit(1); });


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)