Skill: coder-pig/fast-claw-paid-api (version 0.0.4)

--- name: fast-claw-paid-api description: 为 Fast Claw 付费演示微服务获取、保存、充值并使用持久 API key。适用于需要发起购买或充值 checkout 流程、把返回的 API key 本地缓存、查询账户余额，或携带 API key 调用 Fast Claw 微服务的场景。 --- # Fast Claw Paid API 优先使用随 skill 附带的客户端脚本，不要手写 HTTP 调用。这个脚本会处理本地 API key 缓存、发起首购和充值 checkout、轮询支付完成状态，并用认证头去调用服务。 ## 快速开始 如果服务不在 `http://localhost:8033`，先设置 `FAST_CLAW_SERVICE_URL`。 先执行 `python3 scripts/fast_claw_client.py status` 检查本地 API key 状态。 如果还没有 API key，执行 `python3 scripts/fast_claw_client.py purchase --account-name "<账户名>" --credits 10 --open-browser`。 提示用户在浏览器里完成购买页。脚本会持续轮询，支付完成后把 API key 保存到 `~/.fast-claw/api-key.json`，除非用户通过 `FAST_CLAW_API_KEY_PATH` 或兼容变量 `FAST_CLAW_TOKEN_PATH` 指定了别的位置。 正常调用服务时执行 `python3 scripts/fast_claw_client.py invoke --prompt "..."`。 需要生成耗时较长的分析报告时，执行 `python3 scripts/fast_claw_client.py report --prompt "..."`。这个命令会创建异步 report job，并持续轮询直到完成；如果只想先拿 `job_id`，加上 `--no-wait`，之后再用 `wait-report --job-id ...`。 给同一个账户充值时执行 `python3 scripts/fast_claw_client.py topup --credits 10 --open-browser`，不要更换 API key。 如果想验证外部跳转，创建 checkout 时传 `--success-url "https://www.baidu.com"` 即可。 ## 工作流 1. 先用 `status` 查看本地状态。 2. 如果 API key 缺失或失效，执行 `purchase`。 3. 如果服务返回余额不足或次数用尽，执行 `topup`。 4. 需要正式调用时，执行 `invoke`。 5. 需要测试长耗时任务时，执行 `report`，或者先 `report --no-wait` 再 `wait-report`。 6. 如果用户已经从别的入口拿到了 API key，只需要用 `set-api-key` 保存一次。 ## 命令说明 - `status`：显示本地 API key 路径、脱敏后的 API key，以及服务端当前余额。 - `purchase`：为新账户创建 checkout 会话，等待支付完成，并把返回的 API key 持久化到本地。 - `topup`：为当前已保存的 API key 创建充值会话。 - `wait --session-id ...`：如果原来的等待超时了，用这个命令继续轮询已有 checkout 会话。 - `invoke --prompt ...`：消耗 1 次额度并调用演示服务。 - `report --prompt ...`：消耗 1 次额度，创建异步分析报告任务，并默认轮询到完成。 - `wait-report --job-id ...`：继续轮询已有 report job，适合 10 到 20 分钟的长任务。 - `set-api-key --api-key ...`：手动保存从其他来源拿到的 API key；旧命令 `set-token --token ...` 仍可用。 - `clear-api-key`：删除本地保存的 API key；旧命令 `clear-token` 仍可用。 ## 注意事项 - 每个账户只保留一个稳定 API key。充值应该增加账户额度，而不是签发新 API key。 - 本地文件只是 API key 缓存，不是余额来源。真实额度和账户状态始终由后端维护，用户改本地文件只会导致鉴权失败或切到别的有效 API key。 - 只有在你需要查看底层接口细节，或者想替换掉现有客户端脚本时，才去读 `references/service-api.md`。

path: scripts/fast_claw_client.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request
import webbrowser
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Optional


DEFAULT_SERVICE_URL = "http://localhost:8033"
DEFAULT_WAIT_SECONDS = 300
DEFAULT_REPORT_WAIT_SECONDS = 1800
DEFAULT_POLL_INTERVAL = 2.0


def service_url() -> str:
    return os.getenv("FAST_CLAW_SERVICE_URL", DEFAULT_SERVICE_URL).rstrip("/")


def api_key_path() -> Path:
    override = os.getenv("FAST_CLAW_API_KEY_PATH") or os.getenv("FAST_CLAW_TOKEN_PATH")
    if override:
        return Path(override).expanduser()
    default_path = Path.home() / ".fast-claw" / "api-key.json"
    legacy_path = Path.home() / ".fast-claw" / "token.json"
    if not default_path.exists() and legacy_path.exists():
        return legacy_path
    return default_path


def token_path() -> Path:
    return api_key_path()


def read_local_api_key() -> Optional[Dict[str, str]]:
    path = api_key_path()
    if not path.exists():
        return None
    payload = json.loads(path.read_text(encoding="utf-8"))
    if "api_key" not in payload and "token" in payload:
        payload["api_key"] = payload["token"]
    return payload


def read_local_token() -> Optional[Dict[str, str]]:
    return read_local_api_key()


def write_local_api_key(api_key: str) -> None:
    path = api_key_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(
        json.dumps(
            {
                "api_key": api_key,
                "token": api_key,
                "service_url": service_url(),
                "saved_at": datetime.now(timezone.utc).isoformat(),
            },
            indent=2,
        )
        + "\n",
        encoding="utf-8",
    )


def write_local_token(token: str) -> None:
    write_local_api_key(token)


def clear_local_api_key() -> None:
    path = api_key_path()
    if path.exists():
        path.unlink()


def clear_local_token() -> None:
    clear_local_api_key()


def mask_api_key(api_key: str) -> str:
    if len(api_key) <= 12:
        return api_key
    return f"{api_key[:6]}...{api_key[-4:]}"


def mask_token(token: str) -> str:
    return mask_api_key(token)


def request_json(
    method: str,
    path: str,
    payload: Optional[Dict[str, object]] = None,
    [REDACTED_SECRET] = None,
) -> Dict[str, object]:
    headers = {"Accept": "application/json"}
    data = None
    if payload is not None:
        data = json.dumps(payload).encode("utf-8")
        headers["Content-Type"] = "application/json"
    if [REDACTED_SECRET]"X-API-Key"] = api_key
        headers["Authorization"] = f"Bearer {api_key}"
    request = urllib.request.Request(
        f"{service_url()}{path}",
        data=data,
        headers=headers,
        method=method.upper(),
    )
    try:
        with urllib.request.urlopen(request) as response:
            body = response.read().decode("utf-8")
            return json.loads(body) if body else {}
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode("utf-8")
        try:
            detail_obj = json.loads(detail)
            detail = detail_obj.get("detail", detail)
        except json.JSONDecodeError:
            pass
        raise SystemExit(f"HTTP {exc.code}: {detail}") from exc
    except urllib.error.URLError as exc:
        raise SystemExit(f"Could not reach Fast Claw service at {service_url()}: {exc.reason}") from exc


def require_api_key() -> str:
    saved = read_local_api_key()
    if not saved or "api_key" not in saved:
        raise SystemExit(
            "No local API key found. Run `purchase` first or save an existing one with `set-api-key`."
        )
    return str(saved["api_key"])


def require_token() -> str:
    return require_api_key()


def print_json(data: dict[str, object]) -> None:
    print(json.dumps(data, indent=2, ensure_ascii=False))


def wait_for_completion(session_id: str, wait_seconds: int, poll_interval: float) -> Dict[str, object]:
    deadline = time.time() + wait_seconds
    while True:
        session = request_json("GET", f"/v1/checkout-sessions/{session_id}")
        if session.get("status") == "completed":
            return session
        if time.time() >= deadline:
            raise SystemExit(
                "Checkout is still pending. Re-run `wait --session-id "
                f"{session_id}` after the user finishes the checkout page."
            )
        time.sleep(poll_interval)


def wait_for_report(job_id: str, api_key: str, wait_seconds: int, poll_interval: float) -> Dict[str, object]:
    deadline = time.time() + wait_seconds
    while True:
        job = request_json("GET", f"/v1/report-jobs/{job_id}", [REDACTED_SECRET])
        if job.get("status") in {"completed", "failed"}:
            return job
        if time.time() >= deadline:
            raise SystemExit(
                "Report job is still running. Re-run `wait-report --job-id "
                f"{job_id}` to continue polling."
            )
        time.sleep(poll_interval)


def cmd_status(_: argparse.Namespace) -> None:
    saved = read_local_api_key()
    print(f"Service URL: {service_url()}")
    print(f"API key path: {api_key_path()}")
    if not saved:
        print("Local API key: missing")
        return
    [REDACTED_SECRET]"api_key"])
    print(f"Local API key: {mask_api_key(api_key)}")
    remote_status = request_json("GET", "/v1/account", [REDACTED_SECRET])
    print_json(remote_status)


def cmd_purchase(args: argparse.Namespace) -> None:
    session = request_json(
        "POST",
        "/v1/checkout-sessions",
        {
            "account_name": args.account_name,
            "credits": args.credits,
            "success_url": args.success_url,
        },
    )
    run_checkout_wait(session, args)


def cmd_topup(args: argparse.Namespace) -> None:
    session = request_json(
        "POST",
        "/v1/checkout-sessions",
        {
            "api_key": require_api_key(),
            "credits": args.credits,
            "success_url": args.success_url,
        },
    )
    run_checkout_wait(session, args)


def run_checkout_wait(session: Dict[str, object], args: argparse.Namespace) -> None:
    session_id = str(session["session_id"])
    checkout_url = str(session["checkout_url"])
    print(f"Checkout session: {session_id}")
    print(f"Checkout URL: {checkout_url}")
    if args.open_browser:
        webbrowser.open(checkout_url)
    completed = wait_for_completion(session_id, args.wait_seconds, args.poll_interval)
    [REDACTED_SECRET]"api_key") or completed.get("token")
    if [REDACTED_SECRET]))
        print(f"Saved API key to {api_key_path()}")
    print_json(completed)


def cmd_wait(args: argparse.Namespace) -> None:
    completed = wait_for_completion(args.session_id, args.wait_seconds, args.poll_interval)
    [REDACTED_SECRET]"api_key") or completed.get("token")
    if [REDACTED_SECRET]))
        print(f"Saved API key to {api_key_path()}")
    print_json(completed)


def cmd_invoke(args: argparse.Namespace) -> None:
    response = request_json(
        "POST",
        "/v1/invoke",
        {"prompt": args.prompt},
        [REDACTED_SECRET]),
    )
    print_json(response)


def cmd_report(args: argparse.Namespace) -> None:
    [REDACTED_SECRET])
    job = request_json(
        "POST",
        "/v1/report-jobs",
        {"prompt": args.prompt},
        [REDACTED_SECRET],
    )
    print(f"Report job: {job['job_id']}")
    print(f"Poll URL: {job['poll_url']}")
    if args.no_wait:
        print_json(job)
        return
    completed = wait_for_report(job["job_id"], api_key, args.wait_seconds, args.poll_interval)
    if completed.get("status") == "failed":
        raise SystemExit(f"Report job failed: {completed.get('error', 'unknown error')}")
    print_json(completed)


def cmd_wait_report(args: argparse.Namespace) -> None:
    completed = wait_for_report(
        args.job_id,
        require_api_key(),
        args.wait_seconds,
        args.poll_interval,
    )
    if completed.get("status") == "failed":
        raise SystemExit(f"Report job failed: {completed.get('error', 'unknown error')}")
    print_json(completed)


def cmd_set_api_key(args: argparse.Namespace) -> None:
    if not args.api_key:
        raise SystemExit("Provide --api-key.")
    write_local_api_key(args.api_key)
    print(f"Saved API key to {api_key_path()}")


def cmd_set_token(args: argparse.Namespace) -> None:
    write_local_api_key(args.token)
    print(f"Saved API key to {api_key_path()}")


def cmd_clear_token(_: argparse.Namespace) -> None:
    clear_local_api_key()
    print(f"Cleared API key file at {api_key_path()}")


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Fast Claw demo service client")
    subparsers = parser.add_subparsers(dest="command", required=True)

    status_parser = subparsers.add_parser("status", help="Inspect the saved API key and remote balance")
    status_parser.set_defaults(func=cmd_status)

    purchase_parser = subparsers.add_parser("purchase", help="Start a purchase for a new account API key")
    purchase_parser.add_argument("--account-name", required=True, help="Account name to create")
    purchase_parser.add_argument("--credits", type=int, default=10, help="Credits to buy")
    purchase_parser.add_argument("--success-url", help="Optional redirect URL after payment")
    purchase_parser.add_argument(
        "--wait-seconds",
        type=int,
        default=DEFAULT_WAIT_SECONDS,
        help="How long to poll for checkout completion",
    )
    purchase_parser.add_argument(
        "--poll-interval",
        type=float,
        default=DEFAULT_POLL_INTERVAL,
        help="Polling interval in seconds",
    )
    purchase_parser.add_argument(
        "--open-browser",
        action="store_true",
        help="Open the checkout page in the default browser",
    )
    purchase_parser.set_defaults(func=cmd_purchase)

    topup_parser = subparsers.add_parser("topup", help="Recharge credits for the saved API key")
    topup_parser.add_argument("--credits", type=int, default=10, help="Credits to add")
    topup_parser.add_argument("--success-url", help="Optional redirect URL after payment")
    topup_parser.add_argument(
        "--wait-seconds",
        type=int,
        default=DEFAULT_WAIT_SECONDS,
        help="How long to poll for checkout completion",
    )
    topup_parser.add_argument(
        "--poll-interval",
        type=float,
        default=DEFAULT_POLL_INTERVAL,
        help="Polling interval in seconds",
    )
    topup_parser.add_argument(
        "--open-browser",
        action="store_true",
        help="Open the checkout page in the default browser",
    )
    topup_parser.set_defaults(func=cmd_topup)

    wait_parser = subparsers.add_parser("wait", help="Wait for an existing checkout session to complete")
    wait_parser.add_argument("--session-id", required=True, help="Checkout session to poll")
    wait_parser.add_argument(
        "--wait-seconds",
        type=int,
        default=DEFAULT_WAIT_SECONDS,
        help="How long to poll before giving up",
    )
    wait_parser.add_argument(
        "--poll-interval",
        type=float,
        default=DEFAULT_POLL_INTERVAL,
        help="Polling interval in seconds",
    )
    wait_parser.set_defaults(func=cmd_wait)

    invoke_parser = subparsers.add_parser("invoke", help="Call the authenticated Fast Claw endpoint")
    invoke_parser.add_argument("--prompt", required=True, help="Prompt to send to the service")
    invoke_parser.set_defaults(func=cmd_invoke)

    report_parser = subparsers.add_parser("report", help="Start an async analysis report and poll for completion")
    report_parser.add_argument("--prompt", required=True, help="Prompt to send to the report worker")
    report_parser.add_argument(
        "--wait-seconds",
        type=int,
        default=DEFAULT_REPORT_WAIT_SECONDS,
        help="How long to poll for report completion",
    )
    report_parser.add_argument(
        "--poll-interval",
        type=float,
        default=DEFAULT_POLL_INTERVAL,
        help="Polling interval in seconds",
    )
    report_parser.add_argument(
        "--no-wait",
        action="store_true",
        help="Only create the report job and print the job id",
    )
    report_parser.set_defaults(func=cmd_report)

    wait_report_parser = subparsers.add_parser("wait-report", help="Wait for an existing report job to complete")
    wait_report_parser.add_argument("--job-id", required=True, help="Report job to poll")
    wait_report_parser.add_argument(
        "--wait-seconds",
        type=int,
        default=DEFAULT_REPORT_WAIT_SECONDS,
        help="How long to poll before giving up",
    )
    wait_report_parser.add_argument(
        "--poll-interval",
        type=float,
        default=DEFAULT_POLL_INTERVAL,
        help="Polling interval in seconds",
    )
    wait_report_parser.set_defaults(func=cmd_wait_report)

    set_api_key_parser = subparsers.add_parser(
        "set-api-key",
        aliases=["set-token"],
        help="Manually save an API key",
    )
    set_api_key_parser.add_argument("--api-key", dest="api_key", required=False, help="API key to save")
    set_api_key_parser.add_argument("--token", required=False, help="Legacy alias for API key")
    set_api_key_parser.set_defaults(
        func=lambda args: cmd_set_api_key(
            argparse.Namespace([REDACTED_SECRET] or args.token)
        )
    )

    clear_token_parser = subparsers.add_parser(
        "clear-api-key",
        aliases=["clear-token"],
        help="Delete the saved API key file",
    )
    clear_token_parser.set_defaults(func=cmd_clear_token)

    return parser


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()
    args.func(args)
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: agents/openai.yaml
content: interface:
  display_name: "Fast Claw 付费 API"
  short_description: "保存本地 API key 并调用 Fast Claw 付费服务"
  default_prompt: "使用 $fast-claw-paid-api 获取或复用本地 API key，并调用 Fast Claw 付费微服务。"


path: references/service-api.md
content: # Fast Claw 服务接口说明

只有在随附客户端脚本不够用时，才需要读取这个参考文档。

## 本地状态

- 服务地址：`FAST_CLAW_SERVICE_URL`，默认 `http://localhost:8033`
- API key 文件：`FAST_CLAW_API_KEY_PATH`，默认 `~/.fast-claw/api-key.json`
- 兼容旧变量：`FAST_CLAW_TOKEN_PATH`
- 本地文件只是 API key 缓存，余额和账户状态始终以服务端数据库为准。

## 接口列表

### `POST /v1/checkout-sessions`

创建一个 checkout 会话。

请求体：

```json
{
  "account_name": "demo-account",
  "credits": 10,
  "api_key": null,
  "success_url": "http://localhost:3000/after-pay"
}
```

规则：

- 首次购买时传 `account_name`。
- 给已有账户充值时传 `api_key`。
- `success_url` 是可选字段。传了以后，浏览器流程在支付完成后会跳转过去。
- `success_url` 可以是外部链接，例如 `https://www.baidu.com`。

### `GET /v1/checkout-sessions/{session_id}`

轮询 checkout 是否完成。完成后会返回稳定 API key 和当前余额。兼容字段 `token` 也会继续返回。

### `GET /checkout/{session_id}`

打开托管的购买页。

### `POST /checkout/{session_id}/complete`

模拟购买页里的支付成功动作。首次购买会创建 API key，充值则会给现有 API key 增加额度。

### `GET /v1/account`

查询账户状态。

请求头：

```text
X-[REDACTED_SECRET]
```

兼容旧头：

```text
Authorization: Bearer <api_key>
```

`GET /v1/token` 仍保留为兼容别名。

### `POST /v1/invoke`

调用演示微服务。每次请求会消耗 1 次额度。

请求体：

```json
{
  "prompt": "Summarize my request"
}
```

### `POST /v1/report-jobs`

创建一个异步分析报告任务。接口会立即返回 `job_id`，同时消耗 1 次额度。

请求头：

```text
X-[REDACTED_SECRET]
```

请求体：

```json
{
  "prompt": "Analyze refund risk for the last 7 days"
}
```

### `GET /v1/report-jobs/{job_id}`

轮询 report job 的状态。状态会经历 `pending`、`running`，完成后返回 `completed` 和最终报告内容；失败时返回 `failed` 和错误信息。


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