Skill: guillaumemaka/kilocode-model-sync (version 1.0.0)

--- name: kilocode-model-sync description: > Sync the Kilocode provider model list in openclaw.json with the live Kilo AI API. Use when: running the weekly model sync job, checking for new/removed/updated Kilocode models, applying approved model patches to openclaw.json, or reporting sync results to mission control and Telegram. Requires KILOCODE_API_KEY in ~/.openclaw/.env. disable-model-invocation: true --- # ----- THIS SKILL IS DEPRECATED - DON'T RUN ----- # ----- KILOCODE GATEWAY IS NOW BUILT-IN IN OPENCLAW ----- # Kilocode Model Sync **Workspace:** `~/.openclaw/workspace-steve/` **Scripts:** `~/.openclaw/workspace/skills/kilocode-model-sync/scripts/` **Snapshots/diffs/patches:** `~/.openclaw/workspace/kilocode-models/` **Telegram delivery:** Use the `message` tool with `channel=telegram`, target Guillaume's chat. --- ## Step 1 — Run the sync script ```bash source ~/.openclaw/.env && python3 ~/.openclaw/workspace/skills/kilocode-model-sync/scripts/sync_models.py ``` Parse the JSON result from stdout. It contains: | Field | Meaning | |---|---| | `status` | `"changed"` \| `"no_change"` \| `"error"` | | `total_models` | Total models fetched from API | | `diff.added` | New models not in previous snapshot | | `diff.removed` | Models that disappeared | | `diff.updated` | Models with changed fields | | `snapshot_path` | Where today's snapshot was saved | | `patch_path` | Path to the `.patch.json` file (only if changed) | | `diff_path` | Path to the `.diff.json` file (only if changed) | --- ## Step 2 — If `status == "no_change"` Write a brief memory note and stop. No notification needed. ``` # memory/YYYY-MM-DD.md entry: ## Kilocode Model Sync — YYYY-MM-DD - Status: no_change - Total models: X - No action required. ``` --- ## Step 3 — If `status == "changed"`, notify Guillaume via Telegram Use the `message` tool to send to Guillaume's Telegram: ``` 🤖 *Kilocode Model Sync — {date}* Found changes in the Kilocode model list: ✅ Added: {N} model(s) ❌ Removed: {N} model(s) 🔄 Updated: {N} model(s) *New models:* {for each added: • `id` — Name (ctx: Xk tokens, cost: $Y input/$Z output per 1k)} *Removed models:* {for each removed: • `id` — Name} Reply to Grog: `@steve approve` to apply, or `@steve skip` to ignore. ``` Format costs as `$0.001/1k` (multiply the raw per-token value × 1000). Use `free` for models with 0 cost on both input and output. --- ## Step 4 — Wait for approval signal from Grog Grog will relay a `sessions_send` message to your session containing either: - `approve` — proceed to apply - `skip` — log and stop --- ## Step 5 — If approved, apply the patch ```bash python3 ~/.openclaw/workspace/skills/kilocode-model-sync/scripts/apply_patch.py <patch_path> ``` The script: 1. Backs up `openclaw.json` with a timestamp 2. Replaces `models.providers.kilocode.models` with the new list 3. Runs `openclaw gateway restart` 4. Polls `openclaw gateway status` until `RPC probe: ok` (up to 30s) Result fields: - `status`: `"ok"` | `"applied_restart_failed"` - `backup_path`: where the backup was saved - `gateway.ok`: true/false - `gateway.error`: error message if failed --- ## Step 6 — Write mission control entry Append to `~/.openclaw/workspace/memory/YYYY-MM-DD.md`: ```markdown ## Kilocode Model Sync — YYYY-MM-DD - **Status:** Applied ✅ - **Added:** N models — list ids - **Removed:** N models — list ids - **Updated:** N models — list ids - **Backup:** backup_path - **Gateway restart:** ok / failed (error message) - **Approved by:** Guillaume (via Telegram) ``` --- ## Step 7 — Send Telegram confirmation ``` ✅ *Kilocode Model Sync — Applied* {N} new models added to openclaw.json. {N} models removed. Gateway restarted successfully. ✅ Backup saved at: {backup_path} ``` If gateway restart failed: ``` ⚠️ *Kilocode Model Sync — Patch Applied, Gateway Restart Failed* Models were written to openclaw.json but the gateway did not restart cleanly. Error: {error} Please restart manually: `openclaw gateway restart` ``` --- ## Step 8 — Report summary to Grog (sessions_send to main session) After completion, send a concise summary to the main agent session so Grog can relay it to Guillaume if needed. Use `sessions_send` with `label=main` or `sessionKey=agent:main:main`. --- ## Error handling - If `sync_models.py` exits with status `"error"`: notify Guillaume via Telegram with the error, stop. - If `apply_patch.py` fails to write config: the script auto-restores from backup; report failure. - If gateway doesn't come back: report in Telegram, tell Guillaume to restart manually. - Always write a memory note even on failure.

path: scripts/apply_patch.py
content: #!/usr/bin/env python3
"""
apply_patch.py — Apply a kilocode model patch to openclaw.json, then restart gateway.

Usage:
    python3 apply_patch.py <patch_file.patch.json>

Output (stdout): JSON result object
Exit 0: success
Exit 1: error
"""

import json
import os
import shutil
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

OPENCLAW_JSON = Path.home() / ".openclaw" / "openclaw.json"
OPENCLAW_BIN  = Path.home() / ".nvm" / "versions" / "node" / "v24.12.0" / "bin" / "openclaw"


def find_openclaw() -> str:
    """Find the openclaw binary."""
    if OPENCLAW_BIN.exists():
        return str(OPENCLAW_BIN)
    # Try PATH
    result = shutil.which("openclaw")
    if result:
        return result
    raise RuntimeError("openclaw binary not found")


def backup_config() -> Path:
    """Create a timestamped backup of openclaw.json."""
    ts = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
    backup = OPENCLAW_JSON.parent / f"openclaw.json.bak.{ts}"
    shutil.copy2(OPENCLAW_JSON, backup)
    return backup


def apply_models_patch(config: dict, new_models: list) -> dict:
    """Replace models.providers.kilocode.models in config dict."""
    if "models" not in config:
        config["models"] = {}
    if "providers" not in config["models"]:
        config["models"]["providers"] = {}
    if "kilocode" not in config["models"]["providers"]:
        config["models"]["providers"]["kilocode"] = {}
    config["models"]["providers"]["kilocode"]["models"] = new_models
    return config


def restart_gateway(openclaw: str) -> dict:
    """Restart the OpenClaw gateway and wait for it to come back up."""
    # Send restart
    result = subprocess.run(
        [openclaw, "gateway", "restart"],
        capture_output=True, text=True, timeout=30
    )
    if result.returncode != 0:
        return {"ok": False, "error": result.stderr.strip() or "restart failed"}

    # Wait for gateway to come back (poll up to 30s)
    for attempt in range(10):
        time.sleep(3)
        probe = subprocess.run(
            [openclaw, "gateway", "status"],
            capture_output=True, text=True, timeout=10
        )
        if probe.returncode == 0 and "RPC probe: ok" in probe.stdout:
            return {"ok": True, "attempts": attempt + 1}

    return {"ok": False, "error": "Gateway did not come back online within 30s"}


def main():
    if len(sys.argv) < 2:
        print(json.dumps({"status": "error", "error": "Usage: apply_patch.py <patch_file>"}))
        sys.exit(1)

    patch_path = Path(sys.argv[1])
    if not patch_path.exists():
        print(json.dumps({"status": "error", "error": f"Patch file not found: {patch_path}"}))
        sys.exit(1)

    # 1. Load patch
    try:
        patch = json.loads(patch_path.read_text())
    except Exception as e:
        print(json.dumps({"status": "error", "error": f"Invalid patch JSON: {e}"}))
        sys.exit(1)

    if patch.get("applied"):
        print(json.dumps({"status": "error", "error": "Patch already applied"}))
        sys.exit(1)

    new_models = patch.get("models")
    if not new_models:
        print(json.dumps({"status": "error", "error": "Patch has no models list"}))
        sys.exit(1)

    # 2. Load openclaw.json
    if not OPENCLAW_JSON.exists():
        print(json.dumps({"status": "error", "error": f"openclaw.json not found: {OPENCLAW_JSON}"}))
        sys.exit(1)

    try:
        config = json.loads(OPENCLAW_JSON.read_text())
    except Exception as e:
        print(json.dumps({"status": "error", "error": f"Failed to parse openclaw.json: {e}"}))
        sys.exit(1)

    # 3. Backup
    backup_path = backup_config()

    # 4. Apply patch
    try:
        updated_config = apply_models_patch(config, new_models)
        OPENCLAW_JSON.write_text(json.dumps(updated_config, indent=2))
    except Exception as e:
        # Restore backup on failure
        shutil.copy2(backup_path, OPENCLAW_JSON)
        print(json.dumps({"status": "error", "error": f"Failed to write config: {e}", "restored": True}))
        sys.exit(1)

    # 5. Mark patch as applied
    patch["applied"] = True
    patch["applied_at"] = datetime.now(timezone.utc).isoformat()
    patch_path.write_text(json.dumps(patch, indent=2))

    # 6. Restart gateway
    try:
        openclaw_bin = find_openclaw()
        restart_result = restart_gateway(openclaw_bin)
    except Exception as e:
        restart_result = {"ok": False, "error": str(e)}

    result = {
        "status":       "ok" if restart_result["ok"] else "applied_restart_failed",
        "backup_path":  str(backup_path),
        "patch_path":   str(patch_path),
        "models_count": len(new_models),
        "gateway":      restart_result,
    }

    print(json.dumps(result, indent=2))
    sys.exit(0 if restart_result["ok"] else 1)


if __name__ == "__main__":
    main()


path: scripts/sync_models.py
content: #!/usr/bin/env python3
"""
sync_models.py — Fetch Kilocode models and produce diff + patch for openclaw.json

Usage:
    python3 sync_models.py [--output-dir DIR]

Output (stdout): JSON result object
Exit 0: success (changed or no_change)
Exit 1: error
"""

import json
import os
import sys
import re
import argparse
from datetime import date, datetime, timezone
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError

# ── Config ────────────────────────────────────────────────────────────────────
API_URL     = "https://api.kilo.ai/api/gateway/models"
ENV_FILE    = Path.home() / ".openclaw" / ".env"
OPENCLAW_JSON = Path.home() / ".openclaw" / "openclaw.json"
DEFAULT_OUT = Path.home() / ".openclaw" / "workspace" / "kilocode-models"

# ── Helpers ───────────────────────────────────────────────────────────────────

def load_env(path: Path) -> dict:
    env = {}
    if not path.exists():
        return env
    for line in path.read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        if "=" in line:
            k, _, v = line.partition("=")
            env[k.strip()] = v.strip().strip('"').strip("'")
    return env


def fetch_models(token: str) -> list:
    req = Request(API_URL, headers={"Authorization": f"Bearer {token}"})
    with urlopen(req, timeout=30) as resp:
        data = json.loads(resp.read().decode())
    return data.get("data", [])


def map_model(raw: dict) -> dict:
    """Map Kilo API model → OpenClaw schema."""
    params = raw.get("supported_parameters", [])
    pricing = raw.get("pricing", {})
    top = raw.get("top_provider", {})

    def safe_float(val, default=0.0):
        try:
            return float(val) if val is not None else default
        except (ValueError, TypeError):
            return default

    input_modalities = raw.get("architecture", {}).get("input_modalities", ["text"])

    model = {
        "id":            raw["id"],
        "name":          raw.get("name", raw["id"]),
        "reasoning":     "reasoning" in params,
        "cost": {
            "input":  safe_float(pricing.get("prompt"),     0.0),
            "output": safe_float(pricing.get("completion"), 0.0),
        },
        "contextWindow": raw.get("context_length") or top.get("context_length") or 0,
        "maxTokens":     top.get("max_completion_tokens") or 0,
    }

    # Only add image cost if non-zero
    img_cost = safe_float(pricing.get("image"), 0.0)
    if img_cost:
        model["cost"]["image"] = img_cost

    # Add input modalities if not just text
    if input_modalities != ["text"]:
        model["input"] = input_modalities

    return model


def latest_snapshot(out_dir: Path) -> tuple[Path | None, list]:
    """Return (path, models_list) for the most recent snapshot, or (None, [])."""
    snapshots = sorted(out_dir.glob("kilocode-models-????-??-??.json"), reverse=True)
    for snap in snapshots:
        # skip today's (we're about to overwrite)
        today_name = f"kilocode-models-{date.today().isoformat()}.json"
        if snap.name == today_name:
            continue
        try:
            return snap, json.loads(snap.read_text())
        except Exception:
            continue
    return None, []


def compute_diff(old: list, new: list) -> dict:
    """Return {added, removed, updated} as lists of model dicts."""
    old_map = {m["id"]: m for m in old}
    new_map = {m["id"]: m for m in new}

    added   = [new_map[k] for k in new_map if k not in old_map]
    removed = [old_map[k] for k in old_map if k not in new_map]
    updated = [
        {"old": old_map[k], "new": new_map[k]}
        for k in new_map
        if k in old_map and old_map[k] != new_map[k]
    ]
    return {"added": added, "removed": removed, "updated": updated}


def has_changes(diff: dict) -> bool:
    return bool(diff["added"] or diff["removed"] or diff["updated"])


# ── Main ──────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--output-dir", default=str(DEFAULT_OUT))
    args = parser.parse_args()

    out_dir = Path(args.output_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    # 1. Load token
    env = load_env(ENV_FILE)
    # Also check real environment
    [REDACTED_SECRET]"KILOCODE_API_KEY") or env.get("KILOCODE_API_KEY")
    if not [REDACTED_SECRET]"status": "error", "error": "KILOCODE_API_KEY not found in ~/.openclaw/.env"}))
        sys.exit(1)

    # 2. Fetch
    try:
        raw_models = fetch_models(token)
    except HTTPError as e:
        print(json.dumps({"status": "error", "error": f"HTTP {e.code}: {e.reason}"}))
        sys.exit(1)
    except URLError as e:
        print(json.dumps({"status": "error", "error": f"Network error: {e.reason}"}))
        sys.exit(1)

    # 3. Map to OpenClaw schema
    new_models = [map_model(m) for m in raw_models]

    # 4. Save today's snapshot
    today = date.today().isoformat()
    snapshot_path = out_dir / f"kilocode-models-{today}.json"
    snapshot_path.write_text(json.dumps(new_models, indent=2))

    # 5. Load previous snapshot and diff
    prev_path, prev_models = latest_snapshot(out_dir)
    diff = compute_diff(prev_models, new_models)

    result = {
        "status":        "changed" if has_changes(diff) else "no_change",
        "snapshot_path": str(snapshot_path),
        "prev_snapshot": str(prev_path) if prev_path else None,
        "total_models":  len(new_models),
        "diff":          diff,
        "diff_path":     None,
        "patch_path":    None,
    }

    if has_changes(diff):
        # 6. Save diff
        diff_path = out_dir / f"kilocode-models-{today}.diff.json"
        diff_path.write_text(json.dumps(diff, indent=2))
        result["diff_path"] = str(diff_path)

        # 7. Generate patch (the full new models list for openclaw.json)
        patch = {
            "target":  "models.providers.kilocode.models",
            "models":  new_models,
            "applied": False,
            "created": datetime.now(timezone.utc).isoformat(),
        }
        patch_path = out_dir / f"kilocode-models-{today}.patch.json"
        patch_path.write_text(json.dumps(patch, indent=2))
        result["patch_path"] = str(patch_path)

    print(json.dumps(result, indent=2))
    sys.exit(0)


if __name__ == "__main__":
    main()


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