Skill: super-max21/portfolio-diagnosis-public (version 5.0.0)

--- name: portfolio-diagnosis (Public) description: 持仓诊断技能(Tushare驱动版)——专为A股投资者设计。当用户说"帮我诊断持仓"、"看看我的股票组合"、"仓位合理吗"、"持仓风险大吗"、"我的组合夏普比率多少"时触发。使用Tushare SDK获取实时行情和历史数据，进行包含波动率、Beta、夏普比率、最大回撤在内的量化风险诊断，并生成专业诊断报告,包含：英雄区 + 综合评分环形图;持仓概览关键指标卡片;个股明细表格（含仓位进度条）;四大量化指标卡片（波动率/Beta/夏普/回撤）;四张交互式 Chart.js 图表（雷达图/饼图/柱状图/盈亏对比）;六维诊断卡片、风险提示、优化建议;得分明细条形图。 skill_key: portfolio_diagnosis_public original_skill_key: portfolio_diagnosis input_format: "支持以下格式输入持仓数据：\n\n格式1 - 文本格式：\n股票名称 | 买入均价 | 现价 | 持仓数量\n贵州茅台 | 1650\ \ | 1720 | 100\n五粮液 | 135.5 | 142 | 500\n\n格式2 - JSON格式：\n{\n \"holdings\": [\n\ \ {\"code\": \"600519\", \"name\": \"贵州茅台\", \"cost\": 1650, \"shares\": 100},\n\ \ {\"code\": \"000858\", \"name\": \"五粮液\", \"cost\": 135.5, \"shares\": 500}\n\ \ ]\n}\n" trigger: - 帮我诊断持仓 - 看看我的股票组合 - 仓位合理吗 - 我的持仓有没有问题 - 帮我分析一下我买的股票 - 持仓风险大吗 - 帮我做风险分析 - 我的组合夏普比率多少 - 诊断一下我的持仓 --- 本技能为 Prana 封装共享包：`skill_key`（`portfolio_diagnosis_public`）为公开包标识；`original_skill_key`（`portfolio_diagnosis`）为 Prana 上实际调用的专业技能。 运行方式：使用 `scripts/prana_skill_client.py` 或渠道集成入口。

path: README.md
content: # Prana 封装技能（共享包）

本目录为从 Prana 封装下载的**轻量技能包**：不含业务实现代码，含根目录 **`SKILL.md`**、**薄客户端**与 **`skill.yaml`**。frontmatter 中 **`skill_key`** 为公开包标识（Prana 源 `skill_key` + 后缀 **`_public`**），**`original_skill_key`** 为 Prana 上实际执行的专业技能 key。

## 目录说明

封装包**包含** 根目录 **`skill.yaml`**（OpenClaw / Claw Hub 等入口描述，与 `SKILL.md` 配套）。

| 文件/目录 | 说明 |
|-----------|------|
| `SKILL.md` | frontmatter 含 **`skill_key`**（=`{源 skill_key}_public`）、**`original_skill_key`**（= Prana 源技能 key）、`name`、`description` 及可选 `input_format` 等；薄客户端对 **`/api/claw/agent-run` 使用 `original_skill_key`** |
| `skill.yaml` | 含 **`skill_key`**、**`original_skill_key`**（含义同上）及 OpenClaw 字段（`entryPoint` → `scripts/prana_skill_client.py`），封装时生成 |
| `scripts/prana_skill_client.py` | 调用 Prana API 的入口脚本（无业务逻辑） |
| `config/api_key.txt` 或 `config/api_key.json` | 存放 GET /api/v1/api-keys/ 返回的凭证（勿提交到公开仓库） |

公开包自身的标识为 **`{源 skill_key}_public`**（仅用于包名/元数据）；**调用远端时仍使用 `SKILL.md` 中的 `skill_key`（源 key）**。

## `skill.yaml` 说明（不是 `SKILL.yaml`）

根目录文件名为**小写** **`skill.yaml`**，由 **Prana 服务端在封装时自动生成**（调用 `/api/skill/encapsulate/run` 或 `/download` 打入 tar.gz），**无需、也不应**手工编写。

| 项目 | 说明 |
|------|------|
| **作用** | 给 OpenClaw / Claw Hub 等渠道读的**技能清单**：名称、版本、`entryPoint` 指向 `scripts/prana_skill_client.py` |
| **生成来源** | 基于源技能目录的 `SKILL.md`（名称、描述、`trigger`→keywords、`license` 等）+ 当前**上线版本号** |
| **主要字段** | `skill_key`（公开包）、`original_skill_key`（Prana 源技能）、`name`、`version`、`description`、`entryPoint` 等 |
| **可选** | 若源 `SKILL.md` frontmatter 有 `trigger`，会写入 `triggers.keywords` |
| **与 `SKILL.md` 分工** | 两者字段一致；运行时 **`agent-run` 的 `skill_key` 取自 `original_skill_key`** |

## 快速开始

1. **获取 API Key**（首次无本地凭证时）  
   调用 `GET /api/v1/api-keys/`。  
   **可选**查询参数：`account_id`、`phone`、`email`。  
   - **`account_id`**：已有账户 UUID 时传入，可为该账户获取或创建 Key。  
   - **沙盒**：环境变量 **`ACCOUNT_ID`** 由沙盒注入当前账户；若已设置，请求时带上 `?account_id=<ACCOUNT_ID>`；**读不到或未设置则不要传** `account_id`。  

   示例（有 `ACCOUNT_ID` 时）：

   ```bash
   curl -sS "${NEXT_PUBLIC_URL}/api/v1/api-keys/?account_id=${ACCOUNT_ID}"
   ```

   无 `ACCOUNT_ID` 时直接请求根路径即可（仍可用 `phone` / `email` 等，见服务端约定）。

   响应示例：

   ```json
   {
     "code": 200,
     "message": "success",
     "data": {
       "api_key": {
         "public_key": "pk_...",
         "secret_key": "sk_..."
       }
     }
   }
   ```

   将 **`data.api_key`** 中的 `public_key`、`secret_key` 保存为：  
   - **`config/api_key.json`**：粘贴完整 JSON（含 `code`/`data` 均可，脚本会解析）；或  
   - **`config/api_key.txt`**：单行 `public_key:secret_key`（中间一个英文冒号）。

2. **调用技能**

   脚本请求 `POST /api/claw/agent-run` 与（必要时）`POST /api/claw/agent-result` 时，会在请求头加入 **`x-[REDACTED_SECRET]`**（**body 不传 `api_key`**）。  
   - **agent-run** body：`skill_key`（= **`original_skill_key`**，Prana 源技能）、`question`（由 frontmatter 参数说明与用户消息组装）、`thread_id`、`request_id`。  
   - **agent-result** body：仅 `request_id`。  
   `request_id` 不在 `content` 字符串内。

   ```bash
   export NEXT_PUBLIC_URL=https://你的-prana-域名   # 可选，有默认值时请按文档修改
   python3 scripts/prana_skill_client.py -m "用户说的话或任务描述" -t ""
   ```

   第二轮起传入上次响应中的 `thread_id`（若接口返回）：`-t <thread_id>`。

   **长任务与网络异常**：脚本先请求 `POST /api/claw/agent-run`（带 `x-api-key`）；若超时、连接失败、或网关/服务端错误（5xx、408、504），或 200 但响应非合法 JSON，会自动再请求 `POST /api/claw/agent-result`，同样带 `x-api-key`，Body 仅 `request_id`。成功经此路径返回的 JSON 会带 `_recovered_via: "agent-result"` 字段。

3. **环境变量**（可选）

   - `NEXT_PUBLIC_URL`：Prana API 根 URL  
   - `PRANA_SKILL_PUBLIC_KEY` + `PRANA_SKILL_SECRET_KEY`：优先于配置文件  
   - `PRANA_SKILL_API_KEY`：整段 JSON，或单行 `public_key:secret_key`

## 与渠道的集成

各渠道 LLM（Cursor、OpenClaw 等）应在运行本技能时：

1. 检查本地是否已配置 `public_key`/`secret_key`；若无则先 `GET /api/v1/api-keys/`（见 `skill.yaml` / 服务端约定的 create_key 路径）再写入 `api_key.json` 或单行 `api_key.txt`。  
2. 将用户消息作为 `-m` 传入，维护 `thread_id` 实现多轮对话。  
3. 若返回付费链接（HTTP 402 等），按 Prana 文档引导用户完成支付后重试。

## 安全说明

- 勿将 `config/api_key.txt` 提交到公开 Git 仓库。  
- 本包内**不包含**专业技能实现脚本，知识产权保留在 Prana 服务端。


path: _meta.json
content: ownerId: kn7dmckyy6q1334masd7ne8tq983h143
slug: portfolio-diagnosis
version: 1.0.0
publishedAt: 1774404649478

path: config/api_key.txt
content: # 将 Prana GET /api/v1/api-keys/ 返回中的 public_key 与 secret_key 配置到本文件或 config/api_key.json（勿提交到 Git）
# 获取 Key 时可选查询参数 account_id；沙盒环境变量 ACCOUNT_ID 有值时可请求:
#   GET .../api/v1/api-keys/?account_id=${ACCOUNT_ID}
# 无 ACCOUNT_ID 则不要传 account_id
# 方式一（单行）：public_key:secret_key
#   示例：pk_xxx:sk_yyy
# 方式二：将接口完整 JSON 响应保存为 config/api_key.json（推荐）


path: scripts/prana_skill_client.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Prana 封装技能 — 薄客户端（无业务逻辑，仅负责与 Prana 服务通信）
从 GitHub / Claw Hub 等下载本技能包后，通过本脚本将用户输入转发到 Prana 执行真实能力。
"""
from __future__ import annotations

import argparse
import json
import os
import re
import sys
import uuid
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Dict, Optional, Tuple

# 技能根目录（本脚本位于 scripts/ 下）
SKILL_ROOT = Path(__file__).resolve().parent.parent
CONFIG_DIR = SKILL_ROOT / "config"
API_KEY_FILE = CONFIG_DIR / "api_key.txt"
API_KEY_JSON_FILE = CONFIG_DIR / "api_key.json"
SKILL_MD_FILE = SKILL_ROOT / "SKILL.md"

DEFAULT_PRANA_BASE = os.environ.get("NEXT_PUBLIC_URL", "https://suna-dev.ebonex.io")

# agent-run 为长连接场景；超时或失败后改查 agent-result（同一 request_id）
HTTP_TIMEOUT_SEC = 120


def _extract_frontmatter(content: str) -> Tuple[Optional[Dict[str, Any]], str]:
    if not content.strip().startswith("---"):
        return None, content
    match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", content, re.DOTALL)
    if not match:
        return None, content
    try:
        import yaml

        fm = yaml.safe_load(match.group(1))
        return (fm if isinstance(fm, dict) else None), match.group(2)
    except Exception:
        return None, content


def load_skill_config() -> Dict[str, Any]:
    """
    从技能包根目录 SKILL.md 的 YAML frontmatter 读取配置。
    - original_skill_key：Prana 上实际执行的专业技能（agent-run body 使用）。
    - skill_key：公开包标识（一般为 源 key + _public）；若缺 original_skill_key 则回退仅用 skill_key（旧包）。
    """
    if not SKILL_MD_FILE.exists():
        print(
            "错误: 未找到 SKILL.md，请使用完整封装技能包解压后运行。",
            file=sys.stderr,
        )
        sys.exit(1)
    raw = SKILL_MD_FILE.read_text(encoding="utf-8")
    frontmatter, _ = _extract_frontmatter(raw)
    if not frontmatter:
        print("错误: SKILL.md 缺少有效的 YAML frontmatter。", file=sys.stderr)
        sys.exit(1)
    original = str(frontmatter.get("original_skill_key") or "").strip()
    pub = str(frontmatter.get("skill_key") or "").strip()
    # 调用远端：优先 original_skill_key；兼容旧包仅含 skill_key（且为源 key）
    sk = original if original else pub
    if not sk:
        print(
            "错误: SKILL.md frontmatter 需包含 original_skill_key 与 skill_key（或旧包仅 skill_key）。",
            file=sys.stderr,
        )
        sys.exit(1)
    sip = ""
    for key in ("input_format", "input_schema", "params"):
        v = frontmatter.get(key)
        if v is not None and str(v).strip():
            sip = str(v).strip()[:16000]
            break
    if not sip:
        d = frontmatter.get("description")
        if d is not None and str(d).strip():
            sip = str(d).strip()[:8000]
    return {"skill_key": sk, "skill_invocation_params": sip}


def _parse_credentials_json(text: str) -> Optional[Tuple[str, str]]:
    """解析 GET /api/v1/api-keys/ 完整响应，或仅含 public_key / secret_key 的 JSON。"""
    t = text.strip()
    if not t.startswith("{"):
        return None
    try:
        obj = json.loads(t)
    except json.JSONDecodeError:
        return None
    if not isinstance(obj, dict):
        return None
    data = obj.get("data")
    ak = None
    if isinstance(data, dict):
        ak = data.get("api_key")
    if isinstance(ak, dict) and ak.get("public_key") and ak.get("secret_key"):
        return str(ak["public_key"]).strip(), str(ak["secret_key"]).strip()
    ak = obj.get("api_key")
    if isinstance(ak, dict) and ak.get("public_key") and ak.get("secret_key"):
        return str(ak["public_key"]).strip(), str(ak["secret_key"]).strip()
    if obj.get("public_key") and obj.get("secret_key"):
        return str(obj["public_key"]).strip(), str(obj["secret_key"]).strip()
    return None


def _parse_credentials_line(line: str) -> Optional[Tuple[str, str]]:
    """单行 public_key:secret_key（取首个冒号分割，避免密钥中含冒号时误拆）。"""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if ":" not in line:
        return None
    pub, _, sec = line.partition(":")
    pub, sec = pub.strip(), sec.strip()
    if pub and sec:
        return pub, sec
    return None


def _headers_x_api_key(public_key: str, secret_key: str) -> dict[str, str]:
    """Claw 接口要求：x-api-key 为 public_key:secret_key。"""
    return {
        "Content-Type": "application/json",
        "x-api-key": f"{public_key}:{secret_key}",
    }


def load_prana_credentials() -> Tuple[str, str]:
    """
    从环境变量或配置文件读取 public_key、secret_key（来自 GET /api/v1/api-keys/ 返回的 data.api_key）。
    获取 Key 时可选查询参数 account_id；沙盒可从环境变量 ACCOUNT_ID 传入，无则省略。
    优先级：
      PRANA_SKILL_PUBLIC_KEY + PRANA_SKILL_SECRET_KEY
      PRANA_SKILL_API_KEY（整段 JSON，或单行 public_key:secret_key）
      config/api_key.json
      config/api_key.txt（JSON 或单行 public_key:secret_key）
    """
    pk = os.environ.get("PRANA_SKILL_PUBLIC_KEY", "").strip()
    sk = os.environ.get("PRANA_SKILL_SECRET_KEY", "").strip()
    if pk and sk:
        return pk, sk

    raw = os.environ.get("PRANA_SKILL_API_KEY", "").strip()
    if raw:
        parsed = _parse_credentials_json(raw)
        if parsed:
            return parsed
        p = _parse_credentials_line(raw)
        if p:
            return p

    if API_KEY_JSON_FILE.exists():
        parsed = _parse_credentials_json(API_KEY_JSON_FILE.read_text(encoding="utf-8"))
        if parsed:
            return parsed

    if API_KEY_FILE.exists():
        txt = API_KEY_FILE.read_text(encoding="utf-8")
        lines = [ln for ln in txt.splitlines() if ln.strip() and not ln.strip().startswith("#")]
        joined = "\n".join(lines).strip()
        if joined:
            parsed = _parse_credentials_json(joined)
            if parsed:
                return parsed
            for line in lines:
                p = _parse_credentials_line(line)
                if p:
                    return p

    print(
        "错误: 未配置 API 凭证（public_key + secret_key）。\n"
        "  1) 调用 Prana GET /api/v1/api-keys/（可选 account_id、phone、email；沙盒可设 ACCOUNT_ID 后带 ?account_id=…，无则省略），从返回 data.api_key 取得 public_key、secret_key。\n"
        "  2) 将完整 JSON 写入 config/api_key.json，或单行 public_key:secret_key 写入 config/api_key.txt；\n"
        "     或设置 PRANA_SKILL_PUBLIC_KEY + PRANA_SKILL_SECRET_KEY，或 PRANA_SKILL_API_KEY（JSON 或 pk:sk 单行）。",
        file=sys.stderr,
    )
    sys.exit(2)


def build_invoke_content(cfg: dict, user_message: str) -> str:
    """
    组装请求 question：参数说明 + 用户消息。
    skill_key 来自 SKILL.md frontmatter，由调用方单独传入 HTTP body。
    """
    params_from_skill = (cfg.get("skill_invocation_params") or "").strip()
    lines = [
        f"参数：{params_from_skill}",
        f"用户消息：{user_message}",
    ]
    return "\n".join(lines)


def fetch_agent_result(
    base_url: str,
    request_id: str,
    public_key: str,
    secret_key: str,
) -> dict:
    """
    当 agent-run 超时或失败时，用同一 request_id 查询运行结果。
    POST /api/claw/agent-result  body: request_id（鉴权见 x-api-key）
    Header: x-[REDACTED_SECRET]
    """
    url = base_url.rstrip("/") + "/api/claw/agent-result"
    body = {"request_id": request_id}
    data = json.dumps(body, ensure_ascii=False).encode("utf-8")
    req = urllib.request.Request(
        url,
        data=data,
        method="POST",
        headers=_headers_x_api_key(public_key, secret_key),
    )
    try:
        with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_SEC) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        err_body = e.read().decode("utf-8", errors="replace")
        try:
            return json.loads(err_body)
        except json.JSONDecodeError:
            return {
                "error": True,
                "status": e.code,
                "detail": err_body,
                "_from": "agent-result",
            }
    except urllib.error.URLError as e:
        return {"error": True, "detail": str(e.reason), "_from": "agent-result"}


def _mark_recovered(d: dict) -> dict:
    d = dict(d)
    d["_recovered_via"] = "agent-result"
    return d


def invoke_prana(
    base_url: str,
    skill_key: str,
    content: str,
    thread_id: str | None,
    request_id: str,
    public_key: str,
    secret_key: str,
) -> dict:
    """
    调用 Prana 技能执行接口。
    body: skill_key, question, thread_id, request_id（不含 api_key）
    Header: x-[REDACTED_SECRET]
    若 HTTP 超时、连接失败，或网关类错误（5xx / 408 / 504），则改调 agent-result 用 request_id 取结果。
    """
    url = base_url.rstrip("/") + "/api/claw/agent-run"
    body = {
        "skill_key": skill_key,
        "question": content,
        "thread_id": thread_id or "",
        "request_id": request_id,
    }
    data = json.dumps(body, ensure_ascii=False).encode("utf-8")
    req = urllib.request.Request(
        url,
        data=data,
        method="POST",
        headers=_headers_x_api_key(public_key, secret_key),
    )
    try:
        with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_SEC) as resp:
            raw = resp.read().decode("utf-8")
            return json.loads(raw)
    except json.JSONDecodeError:
        return _mark_recovered(fetch_agent_result(base_url, request_id, public_key, secret_key))
    except urllib.error.HTTPError as e:
        err_body = e.read().decode("utf-8", errors="replace")
        # 网关/服务端错误或超时类状态：任务可能已在服务端执行，改查 agent-result
        if e.code >= 500 or e.code in (408, 504):
            return _mark_recovered(fetch_agent_result(base_url, request_id, public_key, secret_key))
        try:
            return json.loads(err_body)
        except json.JSONDecodeError:
            return {"error": True, "status": e.code, "detail": err_body}
    except urllib.error.URLError:
        # 含连接失败、DNS、超时等
        return _mark_recovered(fetch_agent_result(base_url, request_id, public_key, secret_key))
    except TimeoutError:
        return _mark_recovered(fetch_agent_result(base_url, request_id, public_key, secret_key))


def main() -> None:
    parser = argparse.ArgumentParser(description="调用 Prana 远程技能服务")
    parser.add_argument("--message", "-m", required=True, help="用户消息 / 任务描述")
    parser.add_argument("--thread-id", "-t", default="", help="对话 thread_id，首轮可留空")
    parser.add_argument("--base-url", "-b", default=DEFAULT_PRANA_BASE, help="Prana API 根地址")
    args = parser.parse_args()

    cfg = load_skill_config()
    skill_key = cfg.get("skill_key") or ""
    if not skill_key:
        print("错误: 配置中缺少远端 skill_key（请检查 SKILL.md 的 original_skill_key）", file=sys.stderr)
        sys.exit(1)

    public_key, secret_key = load_prana_credentials()
    request_id = str(uuid.uuid4())
    content = build_invoke_content(cfg, args.message)
    result = invoke_prana(
        args.base_url,
        skill_key,
        content,
        args.thread_id or None,
        request_id,
        public_key,
        secret_key,
    )
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: skill.yaml
content: name: portfolio-diagnosis
skill_key: portfolio_diagnosis_public
original_skill_key: portfolio_diagnosis
version: 5.0.0
author: prana
description: 持仓诊断技能(Tushare驱动版)——专为A股投资者设计。当用户说"帮我诊断持仓"、"看看我的股票组合"、"仓位合理吗"、"持仓风险大吗"、"我的组合夏普比率多少"时触发。使用Tushare
  SDK获取实时行情和历史数据，进行包含波动率、Beta、夏普比率、最大回撤在内的量化风险诊断，并生成专业诊断报告,包含：英雄区 + 综合评分环形图;持仓概览关键指标卡片;个股明细表格（含仓位进度条）;四大量化指标卡片（波动率/Beta/夏普/回撤）;四张交互式
  Chart.js 图表（雷达图/饼图/柱状图/盈亏对比）;六维诊断卡片、风险提示、优化建议;得分明细条形图。
license: MIT
permissions:
- network
- filesystem
entryPoint:
  type: shell
  path: scripts/prana_skill_client.py
triggers:
  keywords:
  - 帮我诊断持仓
  - 看看我的股票组合
  - 仓位合理吗
  - 我的持仓有没有问题
  - 帮我分析一下我买的股票
  - 持仓风险大吗
  - 帮我做风险分析
  - 我的组合夏普比率多少
  - 诊断一下我的持仓


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