Skill: 52yuanchangxing/clawhub-rate-limited-publisher-fixed (version 1.0.0)

--- name: clawhub-rate-limited-publisher description: Queue and publish local skills to ClawHub with a strict 5-per-hour cap using the local clawhub CLI and host scheduler. user-invocable: true disable-model-invocation: false metadata: {"openclaw":{"emoji":"🦀","os":["darwin","linux"],"requires":{"bins":["python3","clawhub"]},"homepage":"https://github.com/openclaw/clawhub"}} --- # ClawHub Rate Limited Publisher Use this skill when the user wants to publish one or more local skills to ClawHub without exceeding the platform's publish cap. ## What this skill does This skill does **not** magically grant shell permissions. It provides a safe local queue + scheduler workflow around the user's own `clawhub` CLI. Follow this procedure: 1. Verify the skill folder exists and contains `SKILL.md`. 2. Build or update a queue JSON file. 3. Ask the host to run the helper script from `{baseDir}/scripts/clawhub_rate_limited_uploader.py`. 4. Prefer a host scheduler such as cron or systemd timer so uploads happen automatically every 12 minutes. 5. Never exceed **5 publish attempts in any rolling 3600-second window**. 6. Log stdout/stderr for each attempt and mark queue items as `published` or `failed`. ## Required runtime conditions - `clawhub` must already be installed and authenticated on the host. - The host must allow command execution. In OpenClaw this usually means enabling runtime tools such as `bash`/`exec`, or running the Python script directly outside chat. - New sessions may be required after changing skill/config state because eligible skills are snapshotted per session. ## Recommended invocation patterns ### One-off manual run Run: `python3 "{baseDir}/scripts/clawhub_rate_limited_uploader.py" --queue "/absolute/path/to/queue.json" --execute` ### Dry run Run: `python3 "{baseDir}/scripts/clawhub_rate_limited_uploader.py" --queue "/absolute/path/to/queue.json" --dry-run` ### Cron schedule Run every 12 minutes using the example in `{baseDir}/resources/cron.example`. ## Queue file shape See `{baseDir}/examples/queue.sample.json`. Each item may contain: - `path`: absolute path to one skill directory - `command`: optional command template, default `clawhub publish "{path}"` ## Safety rules - Use absolute paths. - Do not use `curl|bash`, base64 piping, or hidden remote installers. - Keep `command` limited to the local `clawhub publish "{path}"` pattern unless the user explicitly audits and accepts a custom command. - Count failures toward the hourly cap to avoid hammering ClawHub when auth or validation is broken.

path: README.md
content: # ClawHub Rate Limited Publisher

一个用于**本地排队、定时、限速上传 Skill 到 ClawHub** 的辅助 Skill。

## 为什么你之前那个版本“不能用”

根因不是路径 JSON 本身，而是设计假设错位了：

1. **Skill 只是指导与封装，不会自动获得宿主机执行权限。**  
   OpenClaw 的技能文档说明，技能负责把使用说明注入给模型；真正执行本地命令还取决于运行时工具与命令权限。运行宿主机 shell 命令要么依赖运行时工具（`exec` / `bash` / `process`），要么依赖显式开启的 `/bash` 命令。  
2. **OpenClaw 现在有原生 `cron` 工具，但它只是调度能力，不会替你绕过 shell 权限。**
3. **近期 `clawhub publish` 存在 CLI 兼容问题。**  
   2026-03-09 的公开 issue 显示，CLI v0.7.0 可能因为 `acceptLicenseTerms` 字段未正确发送而直接发布失败。

所以，正确方案不是“让 Skill 自己上传”，而是：

- Skill 提供明确的本地上传流程；
- 本地 Python 脚本负责队列、限速、日志、失败重试边界；
- 真正执行由你的 Mac 上的 Python + clawhub CLI 完成；
- 定时由 `cron` 或 `systemd timer` 负责。

## 目录

- `SKILL.md`：技能入口说明
- `README.md`：使用文档
- `SELF_CHECK.md`：自检结果
- `scripts/clawhub_rate_limited_uploader.py`：主脚本
- `resources/cron.example`：cron 示例
- `resources/systemd.timer.example`：systemd timer 示例
- `examples/queue.sample.json`：队列示例
- `tests/smoke-test.md`：冒烟测试

## 快速使用

### 1）先确认单个 skill 能手动发布

```bash
clawhub whoami
clawhub publish [REDACTED_SECRET]
```

如果这里报：

```text
acceptLicenseTerms: invalid value
```

那不是这个 Skill 的问题，而是当前 `clawhub` CLI 版本问题。

### 2）创建 queue.json

可直接复制：

```json
{
  "items": [
    {
      "path": [REDACTED_SECRET]
    }
  ]
}
```

### 3）先 dry-run

```bash
python3 scripts/clawhub_rate_limited_uploader.py   --queue /absolute/path/to/queue.json   --dry-run
```

### 4）确认后执行

```bash
python3 scripts/clawhub_rate_limited_uploader.py   --queue /absolute/path/to/queue.json   --execute
```

## 定时方案

推荐**每 12 分钟执行一次**，脚本内部再做“滚动 1 小时最多 5 次 publish attempt”控制。

这样外层调度和内层限速是双保险。

## 常见问题

### Q1：为什么聊天里不能直接帮我执行？

因为是否允许宿主机执行命令，取决于 OpenClaw 的运行时工具与命令配置，不是单个 Skill 能自行提权解决的。

### Q2：为什么队列里用绝对路径？

因为 OpenClaw / cron / systemd 的工作目录可能不同，绝对路径最稳。

### Q3：失败为什么也算额度？

为了严格遵守平台限制，避免认证失效或参数错误时高频重试。

### Q4：如果 `clawhub publish` 本身坏了怎么办？

先单独跑一次 `clawhub publish`。如果报 license terms 错误，等 CLI 修复后，这个 Skill 的队列和限速逻辑仍可直接继续使用。

## 风险提示

- 第三方 Skill 和本地脚本都应视为可执行代码，先审计再运行。
- 只允许本地受控路径，不要把队列指向未知目录。
- 不要在队列命令里加入远程下载执行链。


path: SELF_CHECK.md
content: # SELF_CHECK

## 规范检查
- [x] 包含 `SKILL.md`
- [x] 包含 `README.md`
- [x] 包含 `SELF_CHECK.md`
- [x] 包含 `scripts/` 且有完整脚本
- [x] 包含 `resources/` 且资源真实被引用
- [x] 包含 `examples/`
- [x] 包含 `tests/smoke-test.md`

## Frontmatter 检查
- [x] 使用 YAML frontmatter
- [x] `name` 存在
- [x] `description` 存在
- [x] `metadata` 使用单行 JSON，符合最新 skills.md 文档约束
- [x] 使用 `requires.bins` 做宿主环境 gating

## 工程检查
- [x] 脚本支持 dry-run
- [x] 脚本支持 execute
- [x] 失败有退出码
- [x] 记录状态文件
- [x] 滚动窗口限速：3600 秒内最多 5 次
- [x] 使用绝对路径更稳
- [x] 无 TODO / 伪代码 / 混淆执行

## 安全检查
- [x] 未使用 `curl|bash`
- [x] 未使用 base64 混淆执行
- [x] 不调用私有未声明 API
- [x] 不绕过 ClawHub 官方 CLI
- [x] 明示依赖：`python3`、`clawhub`
- [x] 明示文件读写范围：队列 JSON、状态 JSON、日志目录

## 可维护性
- [x] 配置集中在脚本参数
- [x] 状态文件为 JSON，便于审计
- [x] 日志输出清晰
- [x] 可由 cron 或 systemd 托管

## 评分
- 规范对齐：9/10
- 实用性：9/10
- 安全性：9/10
- 可维护性：9/10
- 当前外部阻塞：ClawHub CLI 最近存在 publish license terms 兼容问题，不属于本 Skill 内部缺陷


path: examples/queue.sample.json
content: {
  "items": [
    {
      "path": [REDACTED_SECRET]
    }
  ]
}


path: scripts/clawhub_rate_limited_uploader.py
content: \
#!/usr/bin/env python3
"""
Rate-limited ClawHub publisher.

Features:
- Reads queue JSON: {"items":[{"path":"/abs/skill","command":"clawhub publish \"{path}\""}]}
- Enforces max 5 attempts in any rolling 3600 seconds
- Supports --dry-run and --execute
- Stores state in adjacent .publisher-state.json by default
- Logs each attempt
"""

from __future__ import annotations

import argparse
import json
import os
import shlex
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List

MAX_PER_HOUR = 5
WINDOW_SECONDS = 3600
DEFAULT_COMMAND = 'clawhub publish "{path}"'


@dataclass
class QueueItem:
    path: str
    command: str


def load_json(path: Path) -> Dict[str, Any]:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        raise SystemExit(f"Queue/state file not found: {path}")
    except json.JSONDecodeError as exc:
        raise SystemExit(f"Invalid JSON in {path}: {exc}")


def save_json(path: Path, data: Dict[str, Any]) -> None:
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def normalize_items(queue_data: Dict[str, Any]) -> List[QueueItem]:
    items = queue_data.get("items")
    if not isinstance(items, list):
        raise SystemExit('Queue JSON must contain an "items" array.')
    normalized: List[QueueItem] = []
    for index, item in enumerate(items):
        if not isinstance(item, dict):
            raise SystemExit(f"Queue item #{index} must be an object.")
        path = item.get("path")
        if not isinstance(path, str) or not path.strip():
            raise SystemExit(f'Queue item #{index} missing non-empty "path".')
        command = item.get("command", DEFAULT_COMMAND)
        if not isinstance(command, str) or "{path}" not in command:
            raise SystemExit(f'Queue item #{index} has invalid "command"; it must be a string containing "{{path}}".')
        normalized.append(QueueItem(path=path, command=command))
    return normalized


def ensure_skill_dir(path_str: str) -> Path:
    path = Path(path_str).expanduser().resolve()
    if not path.exists():
        raise SystemExit(f"Skill path does not exist: {path}")
    if not path.is_dir():
        raise SystemExit(f"Skill path is not a directory: {path}")
    if not (path / "SKILL.md").exists():
        raise SystemExit(f"Skill directory does not contain SKILL.md: {path}")
    return path


def prune_attempts(attempts: List[Dict[str, Any]], now: float) -> List[Dict[str, Any]]:
    cutoff = now - WINDOW_SECONDS
    return [a for a in attempts if float(a.get("ts", 0)) >= cutoff]


def next_pending_index(state: Dict[str, Any], items: List[QueueItem]) -> int | None:
    statuses = state.setdefault("statuses", {})
    for i, item in enumerate(items):
        key = str(i)
        st = statuses.get(key, {}).get("status", "pending")
        if st not in {"published", "skipped"}:
            return i
    return None


def run_publish(item: QueueItem, execute: bool) -> subprocess.CompletedProcess[str] | None:
    skill_path = ensure_skill_dir(item.path)
    command_str = item.command.format(path=str(skill_path))
    print(f"[info] command: {command_str}")
    if not execute:
        return None
    return subprocess.run(
        command_str,
        shell=True,
        text=True,
        capture_output=True,
        check=False,
        cwd=str(skill_path.parent),
    )


def main() -> int:
    parser = argparse.ArgumentParser(description="Rate-limited ClawHub skill publisher.")
    parser.add_argument("--queue", required=True, help="Path to queue JSON")
    mode = parser.add_mutually_exclusive_group(required=True)
    mode.add_argument("--dry-run", action="store_true", help="Validate and print the next command without executing")
    mode.add_argument("--execute", action="store_true", help="Execute the next publish command")
    parser.add_argument("--state", help="Path to state JSON (default: alongside queue as .publisher-state.json)")
    args = parser.parse_args()

    queue_path = Path(args.queue).expanduser().resolve()
    state_path = Path(args.state).expanduser().resolve() if args.state else queue_path.with_name(".publisher-state.json")

    queue_data = load_json(queue_path)
    items = normalize_items(queue_data)

    state: Dict[str, Any] = {"attempts": [], "statuses": {}}
    if state_path.exists():
        state = load_json(state_path)
        if not isinstance(state, dict):
            raise SystemExit(f"State file must be a JSON object: {state_path}")
        state.setdefault("attempts", [])
        state.setdefault("statuses", {})

    now = time.time()
    state["attempts"] = prune_attempts(state.get("attempts", []), now)
    remaining = MAX_PER_HOUR - len(state["attempts"])
    print(f"[info] rolling-window attempts in last hour: {len(state['attempts'])}/{MAX_PER_HOUR}")

    if remaining <= 0:
        earliest = min(float(a.get("ts", now)) for a in state["attempts"])
        wait_seconds = int((earliest + WINDOW_SECONDS) - now)
        print(f"[warn] hourly cap reached; next slot in about {max(wait_seconds, 0)} seconds")
        save_json(state_path, state)
        return 0

    idx = next_pending_index(state, items)
    if idx is None:
        print("[info] queue complete; nothing pending")
        save_json(state_path, state)
        return 0

    item = items[idx]
    try:
        skill_path = ensure_skill_dir(item.path)
    except SystemExit as exc:
        state["statuses"][str(idx)] = {"status": "failed", "reason": str(exc), "updatedAt": int(now)}
        save_json(state_path, state)
        print(f"[error] {exc}")
        return 2

    print(f"[info] next skill: {skill_path}")
    result = run_publish(item, execute=args.execute)

    if args.dry_run:
        state["statuses"].setdefault(str(idx), {"status": "pending"})
        save_json(state_path, state)
        print("[info] dry-run complete")
        return 0

    attempt_record = {"ts": now, "index": idx, "path": str(skill_path)}
    state["attempts"].append(attempt_record)

    if result is None:
        state["statuses"][str(idx)] = {"status": "failed", "reason": "internal execute error", "updatedAt": int(now)}
        save_json(state_path, state)
        return 3

    stdout = result.stdout.strip()
    stderr = result.stderr.strip()

    if stdout:
        print("[stdout]")
        print(stdout)
    if stderr:
        print("[stderr]", file=sys.stderr)
        print(stderr, file=sys.stderr)

    if result.returncode == 0:
        state["statuses"][str(idx)] = {"status": "published", "updatedAt": int(now)}
        save_json(state_path, state)
        print("[info] publish succeeded")
        return 0

    state["statuses"][str(idx)] = {
        "status": "failed",
        "code": result.returncode,
        "updatedAt": int(now),
        "stderr": stderr[-2000:],
        "stdout": stdout[-2000:],
    }
    save_json(state_path, state)
    print(f"[error] publish failed with exit code {result.returncode}")
    return result.returncode


if __name__ == "__main__":
    raise SystemExit(main())


path: tests/smoke-test.md
content: # Smoke Test

## Preconditions
- `python3 --version`
- `clawhub whoami`
- target skill folder exists and contains `SKILL.md`

## Dry run
```bash
python3 scripts/clawhub_rate_limited_uploader.py --queue examples/queue.sample.json --dry-run
```

Expected:
- prints rolling-window counter
- prints next skill path
- prints the exact `clawhub publish` command
- exits 0

## Execute
```bash
python3 scripts/clawhub_rate_limited_uploader.py --queue examples/queue.sample.json --execute
```

Expected:
- at most one publish attempt per run
- state file `.publisher-state.json` is created next to queue file
- success marks item as `published`
- failure marks item as `failed` and still counts toward hourly cap

## Rate limit verification
Run the execute command repeatedly more than 5 times in an hour.

Expected:
- after 5 attempts in rolling 3600 seconds, script exits 0 without publishing
- it reports roughly how many seconds remain until the next slot


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