Skill: luokeer52/getmeastock (version 1.0.0)

--- name: give-me-a-stock (Public) description: 全面的A股股票分析工具，提供7大核心模块（核心财务、技术指标、估值分析、股东持股、市场热度、券商盈利预测、K线图表）+ 财报、公告、新闻资讯和交易所互动问答的多维度分析。 --- 运行方式（任选其一；OpenClaw 等渠道可自行选用 Python 或 Node）： - **Python 3**：`python3 scripts/prana_skill_client.py -m "…" [-t thread_id] [-b base_url]` - **Node.js 18+**：先在包根目录执行 **`npm install`**，再执行 `node scripts/prana_skill_client.js -m "…" [-t thread_id] [-b base_url]` ## 付费技能说明 若本技能为付费技能，支付成功后请访问 [`https://claw-uat.ebonex.io/api/order/skills`](https://claw-uat.ebonex.io/api/order/skills) 获取购买记录。 鉴权与调用 Claw API 相同：请求头 **`x-api-key`**，值为 **`public_key:secret_key`**（一个英文冒号连接，与 `config/api_key.txt` 中单行凭证格式一致）。

path: package.json
content: {
  "name": "prana-encapsulated-skill",
  "private": true,
  "type": "module",
  "description": "Prana encapsulated skill — install deps for the Node runner: npm install",
  "dependencies": {
    "yaml": "^2.6.1"
  },
  "engines": {
    "node": ">=18.0.0"
  }
}


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

本目录为从 Prana 封装下载的**轻量技能包**：不含业务实现代码，含 **`SKILL.md`**、**`skill.yaml`**、**`package.json`**（供 Node 脚本依赖）与双语言薄客户端。`SKILL.md` / `skill.yaml` **不**展示 `skill_key`、`original_skill_key` 等标识；服务端打包时会将封装元数据 **写入** `scripts/prana_skill_client.py`（`_ENCAPSULATION_EMBEDDED`）与 **`scripts/prana_skill_client.js`**（`ENCAPSULATION_EMBEDDED`），可由渠道或 AI 任选 **Python 3** 或 **Node.js 18+** 运行。

## 目录说明

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

| 文件/目录 | 说明 |
|-----------|------|
| `SKILL.md` | frontmatter 主要含 `name`、`description` 及可选 `input_format` 等；**不含** `skill_key` / `original_skill_key` / `encapsulation_target` |
| `skill.yaml` | OpenClaw 清单：`entryPoint` 默认 Python；`prana_runners` 列出 Node 脚本路径；**不含**业务 skill_key 字段 |
| `scripts/prana_skill_client.py` | Python 入口（无业务逻辑）；已注入 `_ENCAPSULATION_EMBEDDED` |
| `scripts/prana_skill_client.js` | Node 入口（ES Module，与 Python 行为对齐）；已注入 `ENCAPSULATION_EMBEDDED`；需先在包根目录 **`npm install`** |
| `package.json` | Node 依赖声明（当前含 `yaml`，用于解析 `SKILL.md` frontmatter） |
| `config/api_key.txt` | 凭证（单行 `pk:sk` 或粘贴接口 JSON）；可由 `GET /api/v1/api-keys` 手工填入，或由薄客户端自动拉取并写入本文件；接口须带 **`target_system`**，仅 **prana** 使用 `account_id`（见 `config_api_key.txt`） |

**调用远端**时使用脚本内 **`original_skill_key`**（Python `_ENCAPSULATION_EMBEDDED` / Node `ENCAPSULATION_EMBEDDED`，两脚本一致）。元数据中的公开包 id 仍可经下载接口查询。

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

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

| 项目 | 说明 |
|------|------|
| **作用** | 给 OpenClaw / Claw Hub 等读的**技能清单**：名称、版本、`entryPoint`（Python）；`prana_runners` 供选用 Node |
| **生成来源** | 基于源技能目录的 `SKILL.md`（名称、描述、`trigger`→keywords、`license` 等）+ 当前**上线版本号** |
| **主要字段** | `name`、`version`、`description`、`entryPoint`、`prana_runners` 等（不含业务 skill_key；见脚本内嵌） |
| **可选** | 若源 `SKILL.md` frontmatter 有 `trigger`，会写入 `triggers.keywords` |
| **与 `SKILL.md` 分工** | 文档用 frontmatter；运行时 **`agent-run` 的 `skill_key` 取自 Python/Node 任一脚本内嵌** |

## 快速开始

1. **获取 API Key**（首次无本地凭证时）  
   调用 `GET /api/v1/api-keys`。  
   **必带 / 建议带**查询参数 **`target_system`**（与封装平台一致，与脚本内 `encapsulation_target` 一致；**未传时服务端按 prana 处理**）。  
   - **仅当 `target_system=prana` 时**才使用 **`account_id`**；Claw Hub、`claw_hub` 等其它平台**不要传** `account_id`（服务端会忽略误传）。  
   - **沙盒（Prana）**：环境变量 **`ACCOUNT_ID`** 有值时，薄客户端仅在平台为 **prana** 时自动附加 `account_id`。  

   示例（Prana + 已有账户）：

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

   示例（非 Prana，勿带 account_id）：

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

   也可用环境变量 **`ENCAPSULATION_TARGET`**（或 `SKILL_ENCAPSULATION_TARGET`）覆盖包内平台标识。  

   **自动落盘**：Python / Node 任一脚本在拉取成功后会默认写入 **`config/api_key.txt`**（可用 `PRANA_SKILL_SKIP_WRITE_API_KEY=1` 关闭）。

   响应示例：

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

   将 **`data.api_key`** 中的 `public_key`、`secret_key` 写入 **`config/api_key.txt`**：  
   单行 `public_key:secret_key`（一个英文冒号）；或把接口完整 JSON **粘贴进同一文件**（多行亦可，脚本会解析）。

2. **调用技能**（任选 Python 或 Node）

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

   **Python：**

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

   **Node（需先安装依赖）：**

   ```bash
   cd /path/to/技能包根目录
   npm install
   export NEXT_PUBLIC_URL=https://你的-prana-域名   # 可选
   node scripts/prana_skill_client.js -m "用户说的话或任务描述" -t ""
   ```

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

   **长任务与网络异常**：两脚本行为一致——先请求 `POST /api/claw/agent-run`；若超时、连接失败、网关/服务端错误（5xx、408、504），或 **running 但响应非合法 JSON**，会改查 `POST /api/claw/agent-result`。**首次查询前会等待 120 秒，之后若 `data.status` 仍为 `running`，则每隔 120 秒再查一次**，直至结束或达上限（默认 20 次）。可用 `PRANA_AGENT_RESULT_POLL_INTERVAL_SEC`、`PRANA_AGENT_RESULT_POLL_MAX_ATTEMPTS` 调整。成功经此路径返回的 JSON 会带 `_recovered_via: "agent-result"`。

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

   - `NEXT_PUBLIC_URL`：Prana API 根 URL  
   - `ENCAPSULATION_TARGET` / `SKILL_ENCAPSULATION_TARGET`：拉取 key 时的 `target_system`（覆盖脚本内嵌 / 旧版 `SKILL.md`）  
   - `ACCOUNT_ID` / `PRANA_ACCOUNT_ID`：仅 **platform=prana** 时参与 GET `/api/v1/api-keys`  
   - [REDACTED_SECRET]：拉 key 成功不写 `api_key.txt`  
   - `PRANA_SKILL_PUBLIC_KEY` + `PRANA_SKILL_SECRET_KEY`：优先于配置文件  
   - `PRANA_SKILL_API_KEY`：整段 JSON，或单行 `public_key:secret_key`  
   - [REDACTED_SECRET]：改查 `agent-result` 时的间隔秒数（默认 `120`）  
   - `PRANA_AGENT_RESULT_POLL_MAX_ATTEMPTS`：最多轮询次数（默认 `20`）

## 与渠道的集成

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

1. 检查本地是否已配置 `public_key`/`secret_key`；若无则 `GET /api/v1/api-keys`（带合适 **`target_system`**；仅 Prana 带 `account_id`）再写入 **`api_key.txt`**，或依赖薄客户端自动拉取并写 `api_key.txt`。  
2. 根据环境调用 **`prana_skill_client.py`** 或 **`prana_skill_client.js`**（后者需先 `npm install`），将用户消息作为 `-m` 传入，维护 `thread_id` 实现多轮对话。  
3. 若返回付费链接（HTTP 402 等），按文档引导用户完成支付后重试。

## 安全说明

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


path: skill.yaml
content: name: give-me-a-stock
version: 18.0.0
author: prana
description: 全面的A股股票分析工具，提供7大核心模块（核心财务、技术指标、估值分析、股东持股、市场热度、券商盈利预测、K线图表）+ 财报、公告、新闻资讯和交易所互动问答的多维度分析。
license: MIT
permissions:
- network
- filesystem
entryPoint:
  type: shell
  path: scripts/prana_skill_client.py
prana_runners:
  python: scripts/prana_skill_client.py
  node: scripts/prana_skill_client.js
  node_note: 包根目录先执行 npm install（解析 SKILL.md 需 yaml 依赖）
paid_skill:
  order_skills_url: https://claw-uat.ebonex.io/api/order/skills
  after_payment_zh: 付费技能：支付成功后访问 https://claw-uat.ebonex.io/api/order/skills 获取购买记录；请求头
    x-api-key，值为 public_key:secret_key（与 Claw API 一致）。
  authentication:
    header: x-api-key
    value_format: public_key:secret_key


path: scripts/prana_skill_client.js
content: #!/usr/bin/env node
/**
 * Prana 封装技能 — Node 薄客户端（与 prana_skill_client.py 行为对齐）
 * 依赖：Node 18+；包根目录执行 npm install（需 yaml 解析 SKILL.md frontmatter）
 * 本文件为 ES Module（由 package.json 中 "type": "module" 声明）
 */
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SKILL_ROOT = path.resolve(__dirname, '..');
const CONFIG_DIR = path.join(SKILL_ROOT, 'config');
const API_KEY_FILE = path.join(CONFIG_DIR, 'api_key.txt');
const SKILL_MD_FILE = path.join(SKILL_ROOT, 'SKILL.md');

// 封装打包时由服务端替换为对象；仓库模板为 null
const ENCAPSULATION_EMBEDDED = {"public_skill_key": "getmeastock_public", "original_skill_key": "getmeastock", "encapsulation_target": "prana"};

const DEFAULT_PRANA_BASE = 'https://claw-uat.ebonex.io/';

const HTTP_TIMEOUT_MS = 7200 * 1000;
const AGENT_RESULT_POLL_INTERVAL_SEC = Number.parseInt(
  process.env.PRANA_AGENT_RESULT_POLL_INTERVAL_SEC || '120',
  10,
);
const AGENT_RESULT_POLL_MAX_ATTEMPTS = Number.parseInt(
  process.env.PRANA_AGENT_RESULT_POLL_MAX_ATTEMPTS || '20',
  10,
);
const API_KEYS_FETCH_TIMEOUT_MS = 60 * 1000;

function truthyEnv(name) {
  const v = (process.env[name] || '').trim().toLowerCase();
  return v === '1' || v === 'true' || v === 'yes' || v === 'on';
}

function autoFetchApiKeyDisabled() {
  return truthyEnv('PRANA_SKILL_NO_AUTO_API_KEY');
}

function skipWriteFetchedApiKey() {
  return truthyEnv('PRANA_SKILL_SKIP_WRITE_API_KEY');
}

function normalizeEncapsulationTarget(raw) {
  let s = (raw || '').trim().toLowerCase().replace(/-/g, '_');
  if (!s) return 'prana';
  const aliases = { clawhub: 'claw_hub', openclaw: 'claw_hub', open_claw: 'claw_hub' };
  const key = aliases[s] || s;
  return key.length > 64 ? key.slice(0, 64) : key;
}

function loadEncapsulationRuntime() {
  if (ENCAPSULATION_EMBEDDED && typeof ENCAPSULATION_EMBEDDED === 'object') {
    return { ...ENCAPSULATION_EMBEDDED };
  }
  return null;
}

let YAML;
try {
  YAML = (await import('yaml')).default;
} catch {
  YAML = null;
}

function extractFrontmatter(content) {
  const trimmed = content.trimStart();
  if (!trimmed.startsWith('---')) return [null, content];
  const re = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n([\s\S]*)$/;
  const m = trimmed.match(re);
  if (!m) return [null, content];
  if (!YAML) {
    console.error('错误: 解析 SKILL.md 需要依赖 yaml。请在技能包根目录执行: npm install');
    process.exit(1);
  }
  try {
    const fm = YAML.parse(m[1]);
    return [fm && typeof fm === 'object' ? fm : null, m[2]];
  } catch {
    return [null, content];
  }
}

function loadSkillConfig() {
  if (!fs.existsSync(SKILL_MD_FILE)) {
    console.error('错误: 未找到 SKILL.md，请使用完整封装技能包解压后运行。');
    process.exit(1);
  }
  const runtime = loadEncapsulationRuntime();
  const raw = fs.readFileSync(SKILL_MD_FILE, 'utf8');
  const [frontmatter] = extractFrontmatter(raw);
  if (!frontmatter) {
    console.error('错误: SKILL.md 缺少有效的 YAML frontmatter。');
    process.exit(1);
  }
  let original = String((runtime && runtime.original_skill_key) || '').trim();
  const pubFm = String(frontmatter.original_skill_key || '').trim();
  const pubKeyFm = String(frontmatter.skill_key || '').trim();
  if (!original) original = pubFm;
  let pub = String((runtime && runtime.public_skill_key) || '').trim();
  if (!pub) pub = pubKeyFm;
  const sk = original || pub;
  if (!sk) {
    console.error(
      '错误: 缺少远端技能标识。请使用服务端封装生成的技能包（脚本内已写入 ENCAPSULATION_EMBEDDED），' +
        '或为旧版包在 SKILL.md frontmatter 中保留 original_skill_key / skill_key。',
    );
    process.exit(1);
  }
  let sip = '';
  for (const key of ['input_format', 'input_schema', 'params']) {
    const v = frontmatter[key];
    if (v != null && String(v).trim()) {
      sip = String(v).trim().slice(0, 16000);
      break;
    }
  }
  if (!sip) {
    const d = frontmatter.description;
    if (d != null && String(d).trim()) sip = String(d).trim().slice(0, 8000);
  }
  const enc = normalizeEncapsulationTarget(
    String(
      (runtime && runtime.encapsulation_target) ||
        frontmatter.encapsulation_target ||
        '',
    ),
  );
  return { skill_key: sk, skill_invocation_params: sip, encapsulation_target: enc };
}

function parseCredentialsJson(text) {
  const t = text.trim();
  if (!t.startsWith('{')) return null;
  try {
    const obj = JSON.parse(t);
    if (!obj || typeof obj !== 'object') return null;
    const data = obj.data;
    let ak = null;
    if (data && typeof data === 'object') ak = data.api_key;
    if (ak && typeof ak === 'object' && ak.public_key && ak.secret_key) {
      return [String(ak.public_key).trim(), String(ak.secret_key).trim()];
    }
    ak = obj.api_key;
    if (ak && typeof ak === 'object' && ak.public_key && ak.secret_key) {
      return [String(ak.public_key).trim(), String(ak.secret_key).trim()];
    }
    if (obj.public_key && obj.secret_key) {
      return [String(obj.public_key).trim(), String(obj.secret_key).trim()];
    }
  } catch {
    return null;
  }
  return null;
}

function parseCredentialsLine(line) {
  line = line.trim();
  if (!line || line.startsWith('#')) return null;
  const i = line.indexOf(':');
  if (i === -1) return null;
  const pub = line.slice(0, i).trim();
  const sec = line.slice(i + 1).trim();
  if (pub && sec) return [pub, sec];
  return null;
}

function headersXApiKey(publicKey, secretKey) {
  return {
    'Content-Type': 'application/json',
    'x-api-key': `${publicKey}:${secretKey}`,
  };
}

function encapsulationTargetForApiKeysRequest() {
  const v = (
    process.env.ENCAPSULATION_TARGET ||
    process.env.SKILL_ENCAPSULATION_TARGET ||
    process.env.PRANA_ENCAPSULATION_TARGET ||
    ''
  ).trim();
  if (v) return normalizeEncapsulationTarget(v);
  const rt = loadEncapsulationRuntime();
  if (rt && String(rt.encapsulation_target || '').trim()) {
    return normalizeEncapsulationTarget(String(rt.encapsulation_target || ''));
  }
  if (fs.existsSync(SKILL_MD_FILE)) {
    try {
      const raw = fs.readFileSync(SKILL_MD_FILE, 'utf8');
      const [fm] = extractFrontmatter(raw);
      if (fm) return normalizeEncapsulationTarget(String(fm.encapsulation_target || ''));
    } catch {
      /* ignore */
    }
  }
  return 'prana';
}

function buildApiKeysFetchUrl(baseUrl) {
  const root = baseUrl.replace(/\/+$/, '');
  const q = new URLSearchParams();
  const ts = encapsulationTargetForApiKeysRequest();
  q.set('target_system', ts);
  if (ts === 'prana') {
    const aid = (process.env.ACCOUNT_ID || process.env.PRANA_ACCOUNT_ID || '').trim();
    if (aid) q.set('account_id', aid);
  }
  const qs = q.toString();
  return qs ? `${root}/api/v1/api-keys?${qs}` : `${root}/api/v1/api-keys`;
}

async function fetchPranaApiKeysViaGet(baseUrl) {
  const url = buildApiKeysFetchUrl(baseUrl);
  try {
    const res = await fetch(url, {
      method: 'GET',
      signal: AbortSignal.timeout(API_KEYS_FETCH_TIMEOUT_MS),
    });
    const text = await res.text();
    if (!res.ok) {
      console.error(`错误: 自动获取 API key 失败（HTTP ${res.status}）：${text.slice(0, 2000)}`);
      return null;
    }
    const parsed = parseCredentialsJson(text);
    if (!parsed) {
      console.error('错误: 自动获取 API key 成功但响应无法解析出 public_key/secret_key。');
      return null;
    }
    return parsed;
  } catch (e) {
    console.error(`错误: 自动获取 API key 失败（网络）：${e && e.message ? e.message : e}`);
    return null;
  }
}

function persistFetchedApiKeyTxt(publicKey, secretKey) {
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
  const lines = [
    '# Auto-saved by prana_skill_client after GET /api/v1/api-keys; do not commit to public repos.',
    `${publicKey}:${secretKey}`,
    '',
  ];
  fs.writeFileSync(API_KEY_FILE, lines.join('\n'), 'utf8');
}

async function loadPranaCredentials(pranaBaseUrl) {
  let pk = (process.env.PRANA_SKILL_PUBLIC_KEY || '').trim();
  let sk = (process.env.PRANA_SKILL_SECRET_KEY || '').trim();
  if (pk && sk) return [pk, sk];

  const rawEnv = (process.env.PRANA_SKILL_API_KEY || '').trim();
  if (rawEnv) {
    const parsed = parseCredentialsJson(rawEnv);
    if (parsed) return parsed;
    const pl = parseCredentialsLine(rawEnv);
    if (pl) return pl;
  }

  if (fs.existsSync(API_KEY_FILE)) {
    const txt = fs.readFileSync(API_KEY_FILE, 'utf8');
    const lines = txt
      .split(/\r?\n/)
      .filter((ln) => ln.trim() && !ln.trim().startsWith('#'));
    const joined = lines.join('\n').trim();
    if (joined) {
      const pj = parseCredentialsJson(joined);
      if (pj) return pj;
      for (const line of lines) {
        const pl = parseCredentialsLine(line);
        if (pl) return pl;
      }
    }
  }

  const base = (
    pranaBaseUrl ||
    process.env.NEXT_PUBLIC_URL ||
    DEFAULT_PRANA_BASE ||
    ''
  ).trim();
  if (base && !autoFetchApiKeyDisabled()) {
    const fetched = await fetchPranaApiKeysViaGet(base);
    if (fetched) {
      const [pub, sec] = fetched;
      if (!skipWriteFetchedApiKey()) {
        try {
          persistFetchedApiKeyTxt(pub, sec);
        } catch (e) {
          console.error(`警告: 无法写入 config/api_key.txt：${e && e.message ? e.message : e}`);
        }
      }
      return [pub, sec];
    }
  }

  console.error(
    '错误: 未配置 API 凭证（public_key + secret_key），且自动 GET /api/v1/api-keys 失败或未启用。\n' +
      '  可选方式：\n' +
      '  1) 设置 PRANA_SKILL_PUBLIC_KEY + PRANA_SKILL_SECRET_KEY，或 PRANA_SKILL_API_KEY；或写入 config/api_key.txt。\n' +
      '  2) 保证 --base-url（或 NEXT_PUBLIC_URL）可访问，并确保未设置 PRANA_SKILL_NO_AUTO_API_KEY；' +
      '请求会带 target_system；仅 prana 时可设 ACCOUNT_ID；成功后默认写入 config/api_key.txt（' +
      '若不想写盘可设 PRANA_SKILL_SKIP_WRITE_API_KEY=1）。',
  );
  process.exit(2);
}

function buildInvokeContent(cfg, userMessage) {
  const paramsFromSkill = (cfg.skill_invocation_params || '').trim();
  return [`参数：${paramsFromSkill}`, `用户消息：${userMessage}`].join('\n');
}

async function fetchAgentResult(baseUrl, requestId, publicKey, secretKey) {
  const url = `${baseUrl.replace(/\/+$/, '')}/api/claw/agent-result`;
  const body = { request_id: requestId };
  try {
    const res = await fetch(url, {
      method: 'POST',
      headers: headersXApiKey(publicKey, secretKey),
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
    });
    const text = await res.text();
    if (!res.ok) {
      try {
        return JSON.parse(text);
      } catch {
        return {
          error: true,
          status: res.status,
          detail: text,
          _from: 'agent-result',
        };
      }
    }
    try {
      return JSON.parse(text);
    } catch {
      return {
        error: true,
        status: res.status,
        detail: text,
        _from: 'agent-result',
      };
    }
  } catch (e) {
    return {
      error: true,
      detail: String(e && e.reason != null ? e.reason : e && e.message ? e.message : e),
      _from: 'agent-result',
    };
  }
}

function agentResultPayloadStillRunning(payload) {
  if (payload.error === true) return false;
  const data = payload.data;
  if (!data || typeof data !== 'object') return false;
  return String(data.status || '')
    .trim()
    .toLowerCase() === 'running';
}

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

function markRecovered(d) {
  return { ...d, _recovered_via: 'agent-result' };
}

async function pollAgentResultUntilSettled(
  baseUrl,
  requestId,
  publicKey,
  secretKey,
  triggerReason = '需通过 agent-result 拉取结果',
) {
  const interval = Math.max(1, AGENT_RESULT_POLL_INTERVAL_SEC);
  const maxAttempts = Math.max(1, AGENT_RESULT_POLL_MAX_ATTEMPTS);
  let last = {};
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    if (attempt === 1) {
      console.error(
        `提示: ${triggerReason}，${interval} 秒后首次 POST /api/claw/agent-result … ` +
          `(request_id=${requestId}, 最多 ${maxAttempts} 次，每 ${interval}s)`,
      );
    } else {
      console.error(`提示: 第 ${attempt} 次查询 agent-result（间隔 ${interval}s）…`);
    }
    await sleep(interval * 1000);
    last = await fetchAgentResult(baseUrl, requestId, publicKey, secretKey);
    if (agentResultPayloadStillRunning(last)) continue;
    return markRecovered(last);
  }
  console.error(`警告: agent-result 已轮询 ${maxAttempts} 次仍未结束，返回最后一次响应。`);
  return markRecovered(last);
}

async function invokePrana(
  baseUrl,
  skillKey,
  content,
  threadId,
  requestId,
  publicKey,
  secretKey,
) {
  const url = `${baseUrl.replace(/\/+$/, '')}/api/claw/agent-run`;
  const body = {
    skill_key: skillKey,
    question: content,
    thread_id: threadId || '',
    request_id: requestId,
  };
  const opts = {
    method: 'POST',
    headers: headersXApiKey(publicKey, secretKey),
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
  };
  try {
    const res = await fetch(url, opts);
    const raw = await res.text();
    if (!res.ok) {
      if (res.status >= 500 || res.status === 408 || res.status === 504) {
        return pollAgentResultUntilSettled(
          baseUrl,
          requestId,
          publicKey,
          secretKey,
          `agent-run HTTP ${res.status}，改查 agent-result`,
        );
      }
      try {
        return JSON.parse(raw);
      } catch {
        return { error: true, status: res.status, detail: raw };
      }
    }
    try {
      return JSON.parse(raw);
    } catch {
      return pollAgentResultUntilSettled(
        baseUrl,
        requestId,
        publicKey,
        secretKey,
        'agent-run 响应非合法 JSON',
      );
    }
  } catch (e) {
    return pollAgentResultUntilSettled(
      baseUrl,
      requestId,
      publicKey,
      secretKey,
      'agent-run 网络异常',
    );
  }
}

async function main() {
  let values;
  try {
    const parsed = parseArgs({
      args: process.argv.slice(2),
      options: {
        message: { type: 'string', short: 'm' },
        'thread-id': { type: 'string', short: 't', default: '' },
        'base-url': { type: 'string', short: 'b', default: DEFAULT_PRANA_BASE },
        help: { type: 'boolean', short: 'h', default: false },
      },
      allowPositionals: false,
    });
    values = parsed.values;
    if (values.help) {
      console.log(
        '用法: node scripts/prana_skill_client.js -m "用户消息" [-t thread_id] [-b base_url]',
      );
      process.exit(0);
    }
    if (!values.message) {
      console.error('错误: 必须使用 -m / --message 提供用户消息 / 任务描述');
      process.exit(1);
    }
  } catch (e) {
    console.error(e && e.message ? e.message : e);
    process.exit(1);
  }

  const cfg = loadSkillConfig();
  const skillKey = cfg.skill_key || '';
  if (!skillKey) {
    console.error('错误: 配置中缺少远端 skill_key（请检查脚本内 ENCAPSULATION_EMBEDDED 或旧版 SKILL.md）');
    process.exit(1);
  }

  const baseUrl = values['base-url'] || DEFAULT_PRANA_BASE;
  const [publicKey, secretKey] = await loadPranaCredentials(baseUrl);

  const requestId = randomUUID();
  const content = buildInvokeContent(cfg, values.message);
  const result = await invokePrana(
    baseUrl,
    skillKey,
    content,
    values['thread-id'] || null,
    requestId,
    publicKey,
    secretKey,
  );
  console.log(JSON.stringify(result, null, 2));
}

await main();


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 time
import uuid
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode

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

# 封装打包时由服务端替换为 dict（见锚点注释）；仓库模板为 None，仅供开发或旧版 frontmatter 回退。
_ENCAPSULATION_EMBEDDED: Optional[Dict[str, Any]] = {"public_skill_key": "getmeastock_public", "original_skill_key": "getmeastock", "encapsulation_target": "prana"}

DEFAULT_PRANA_BASE = "https://claw-uat.ebonex.io/"

# agent-run 为长连接场景；超时或失败后改查 agent-result（同一 request_id）
HTTP_TIMEOUT_SEC = 7200
# agent-run 非合法 JSON 或需改查时，按固定间隔轮询 POST /api/claw/agent-result（默认每 2 分钟）
AGENT_RESULT_POLL_INTERVAL_SEC = int(os.environ.get("PRANA_AGENT_RESULT_POLL_INTERVAL_SEC", "120"))
AGENT_RESULT_POLL_MAX_ATTEMPTS = int(os.environ.get("PRANA_AGENT_RESULT_POLL_MAX_ATTEMPTS", "20"))
# 自动拉取 API key（GET /api/v1/api-keys）超时
API_KEYS_FETCH_TIMEOUT_SEC = 60


def _auto_fetch_api_key_disabled() -> bool:
    """PRANA_SKILL_NO_AUTO_API_KEY=1 时不在本地缺省时自动请求 create_key 接口。"""
    v = os.environ.get("PRANA_SKILL_NO_AUTO_API_KEY", "").strip().lower()
    return v in ("1", "true", "yes", "on")


def _skip_write_fetched_api_key() -> bool:
    """PRANA_SKILL_SKIP_WRITE_API_KEY=1 时不把接口拉取的 key 写入磁盘（默认会写入 config/api_key.txt）。"""
    v = os.environ.get("PRANA_SKILL_SKIP_WRITE_API_KEY", "").strip().lower()
    return v in ("1", "true", "yes", "on")


def _normalize_encapsulation_target(raw: Optional[str]) -> str:
    """与后端 encapsulation target_system 一致：默认 prana；clawHub 等归一为 claw_hub。"""
    s = (raw or "").strip().lower().replace("-", "_")
    if not s:
        return "prana"
    aliases = {"clawhub": "claw_hub", "openclaw": "claw_hub", "open_claw": "claw_hub"}
    key = aliases.get(s, s)
    return key[:64] if len(key) > 64 else key


def _load_encapsulation_runtime() -> Optional[Dict[str, Any]]:
    """封装包内写入 `prana_skill_client.py` 的 `_ENCAPSULATION_EMBEDDED`；未封装/模板为 None。"""
    emb = _ENCAPSULATION_EMBEDDED
    if isinstance(emb, dict) and emb:
        return dict(emb)
    return None


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]:
    """
    从本脚本内嵌 `_ENCAPSULATION_EMBEDDED` 与 SKILL.md frontmatter 合并读取配置。
    agent-run 使用的 skill_key：优先内嵌 `original_skill_key`，其次旧版 SKILL.md 字段。
    """
    if not SKILL_MD_FILE.exists():
        print(
            "错误: 未找到 SKILL.md，请使用完整封装技能包解压后运行。",
            file=sys.stderr,
        )
        sys.exit(1)
    runtime = _load_encapsulation_runtime()
    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((runtime or {}).get("original_skill_key") or "").strip()
    pub_fm = str(frontmatter.get("original_skill_key") or "").strip()
    pub_key_fm = str(frontmatter.get("skill_key") or "").strip()
    if not original:
        original = pub_fm
    pub = str((runtime or {}).get("public_skill_key") or "").strip()
    if not pub:
        pub = pub_key_fm
    # 调用远端：优先 original_skill_key；再回退旧包仅含 skill_key
    sk = original if original else pub
    if not sk:
        print(
            "错误: 缺少远端技能标识。请使用服务端封装生成的技能包（脚本内已写入 _ENCAPSULATION_EMBEDDED），"
            "或为旧版包在 SKILL.md frontmatter 中保留 original_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]
    enc = _normalize_encapsulation_target(
        str((runtime or {}).get("encapsulation_target") or frontmatter.get("encapsulation_target") or "")
    )
    return {"skill_key": sk, "skill_invocation_params": sip, "encapsulation_target": enc}


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 _encapsulation_target_for_api_keys_request() -> str:
    """
    GET /api/v1/api-keys 的 target_system：环境变量优先，否则本脚本内嵌字段，再 SKILL.md，默认 prana。
    非 prana 平台不应带 account_id（由服务端忽略误传的 account_id）。
    """
    v = (
        os.environ.get("ENCAPSULATION_TARGET")
        or os.environ.get("SKILL_ENCAPSULATION_TARGET")
        or os.environ.get("PRANA_ENCAPSULATION_TARGET")
        or ""
    ).strip()
    if v:
        return _normalize_encapsulation_target(v)
    rt = _load_encapsulation_runtime()
    if rt and str(rt.get("encapsulation_target") or "").strip():
        return _normalize_encapsulation_target(str(rt.get("encapsulation_target") or ""))
    if SKILL_MD_FILE.exists():
        try:
            raw = SKILL_MD_FILE.read_text(encoding="utf-8")
            fm, _ = _extract_frontmatter(raw)
            if fm:
                return _normalize_encapsulation_target(str(fm.get("encapsulation_target") or ""))
        except OSError:
            pass
    return "prana"


def _build_api_keys_fetch_url(base_url: str) -> str:
    """
    组装 GET /api/v1/api-keys 完整 URL。
    始终传 target_system；仅 platform=prana 时附加 account_id（ACCOUNT_ID / PRANA_ACCOUNT_ID）。
    """
    root = base_url.rstrip("/")
    path = f"{root}/api/v1/api-keys"
    q: Dict[str, str] = {}
    ts = _encapsulation_target_for_api_keys_request()
    q["target_system"] = ts
    if ts == "prana":
        aid = (os.environ.get("ACCOUNT_ID") or os.environ.get("PRANA_ACCOUNT_ID") or "").strip()
        if aid:
            q["account_id"] = aid
    if q:
        path = f"{path}?{urlencode(q)}"
    return path


def fetch_prana_api_keys_via_get(base_url: str) -> Optional[Tuple[str, str]]:
    """
    调用 Prana GET /api/v1/api-keys（无需 JWT），解析 data.api_key 的 public_key、secret_key。
    """
    url = _build_api_keys_fetch_url(base_url)
    req = urllib.request.Request(url, method="GET")
    try:
        with urllib.request.urlopen(req, timeout=API_KEYS_FETCH_TIMEOUT_SEC) as resp:
            text = resp.read().decode("utf-8")
    except urllib.error.HTTPError as e:
        err = e.read().decode("utf-8", errors="replace")
        print(
            f"错误: 自动获取 API key 失败（HTTP {e.code}）：{err[:2000]}",
            file=sys.stderr,
        )
        return None
    except urllib.error.URLError as e:
        print(f"错误: 自动获取 API key 失败（网络）：{e.reason}", file=sys.stderr)
        return None
    except TimeoutError:
        print("错误: 自动获取 API key 超时。", file=sys.stderr)
        return None
    parsed = _parse_credentials_json(text)
    if not parsed:
        print(
            "错误: 自动获取 API key 成功但响应无法解析出 public_key/secret_key。",
            file=sys.stderr,
        )
        return None
    return parsed


def _persist_fetched_api_key_txt(public_key: str, secret_key: str) -> None:
    """将 public_key:secret_key 写入 config/api_key.txt（首行为注释，与现有读取逻辑兼容）。"""
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    lines = [
        "# Auto-saved by prana_skill_client after GET /api/v1/api-keys; do not commit to public repos.",
        f"{public_key}:{secret_key}",
        "",
    ]
    API_KEY_FILE.write_text("\n".join(lines), encoding="utf-8")


def load_prana_credentials(prana_base_url: Optional[str] = None) -> Tuple[str, str]:
    """
    从环境变量或配置文件读取 public_key、secret_key（来自 GET /api/v1/api-keys 返回的 data.api_key）。
    自动拉取时带 target_system（脚本内嵌、SKILL.md 或 ENCAPSULATION_TARGET）；仅 prana 时附加 account_id（ACCOUNT_ID）。
    若以上皆无且未禁止自动拉取，则发起 GET /api/v1/api-keys（见 _build_api_keys_fetch_url）。
    优先级：
      PRANA_SKILL_PUBLIC_KEY + PRANA_SKILL_SECRET_KEY
      PRANA_SKILL_API_KEY（整段 JSON，或单行 public_key:secret_key）
      config/api_key.txt（单行 public_key:secret_key，或粘贴整段 JSON）
      GET /api/v1/api-keys（需可达的 base_url；成功后默认写入 config/api_key.txt）
    """
    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_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

    base = (prana_base_url or os.environ.get("NEXT_PUBLIC_URL") or DEFAULT_PRANA_BASE or "").strip()
    if base and not _auto_fetch_api_key_disabled():
        fetched = fetch_prana_api_keys_via_get(base)
        if fetched:
            pub, sec = fetched
            if not _skip_write_fetched_api_key():
                try:
                    _persist_fetched_api_key_txt(pub, sec)
                except OSError as e:
                    print(f"警告: 无法写入 config/api_key.txt：{e}", file=sys.stderr)
            return pub, sec

    print(
        "错误: 未配置 API 凭证（public_key + secret_key），且自动 GET /api/v1/api-keys 失败或未启用。\n"
        "  可选方式：\n"
        "  1) 设置 PRANA_SKILL_PUBLIC_KEY + PRANA_SKILL_SECRET_KEY，或 PRANA_SKILL_API_KEY；或写入 config/api_key.txt。\n"
        "  2) 保证 --base-url（或 NEXT_PUBLIC_URL）可访问，并确保未设置 PRANA_SKILL_NO_AUTO_API_KEY；"
        "请求会带 target_system；仅 prana 时可设 ACCOUNT_ID；成功后默认写入 config/api_key.txt（"
        "若不想写盘可设 PRANA_SKILL_SKIP_WRITE_API_KEY=1）。",
        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 _agent_result_payload_still_running(payload: dict) -> bool:
    """Claw 返回 data.status == running 时继续轮询。"""
    if payload.get("error") is True:
        return False
    data = payload.get("data")
    if not isinstance(data, dict):
        return False
    return str(data.get("status") or "").strip().lower() == "running"


def _poll_agent_result_until_settled(
    base_url: str,
    request_id: str,
    public_key: str,
    secret_key: str,
    *,
    trigger_reason: str = "需通过 agent-result 拉取结果",
) -> dict:
    """
    每隔 AGENT_RESULT_POLL_INTERVAL_SEC 秒请求一次 POST /api/claw/agent-result，
    直至 data.status 不为 running（或响应不含 running）、或达到最大次数。
    首次请求前也会先等待一个间隔（agent-run 刚返回非 JSON 时任务往往尚未写入结果）。
    """
    interval = max(1, AGENT_RESULT_POLL_INTERVAL_SEC)
    max_attempts = max(1, AGENT_RESULT_POLL_MAX_ATTEMPTS)
    last: dict = {}
    for attempt in range(1, max_attempts + 1):
        if attempt == 1:
            print(
                f"提示: {trigger_reason}，{interval} 秒后首次 POST /api/claw/agent-result … "
                f"(request_id={request_id}, 最多 {max_attempts} 次，每 {interval}s)",
                file=sys.stderr,
            )
        else:
            print(
                f"提示: 第 {attempt} 次查询 agent-result（间隔 {interval}s）…",
                file=sys.stderr,
            )
        time.sleep(interval)
        last = fetch_agent_result(base_url, request_id, public_key, secret_key)
        if _agent_result_payload_still_running(last):
            continue
        return _mark_recovered(last)

    print(
        f"警告: agent-result 已轮询 {max_attempts} 次仍未结束，返回最后一次响应。",
        file=sys.stderr,
    )
    return _mark_recovered(last)


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），或 200 但响应非合法 JSON，则改调 agent-result：
    自首次查询起按 AGENT_RESULT_POLL_INTERVAL_SEC（默认 120s）间隔轮询，直至非 running 或达上限。
    """
    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 _poll_agent_result_until_settled(
            base_url,
            request_id,
            public_key,
            secret_key,
            trigger_reason="agent-run 响应非合法 JSON",
        )
    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 _poll_agent_result_until_settled(
                base_url,
                request_id,
                public_key,
                secret_key,
                trigger_reason=f"agent-run HTTP {e.code}，改查 agent-result",
            )
        try:
            return json.loads(err_body)
        except json.JSONDecodeError:
            return {"error": True, "status": e.code, "detail": err_body}
    except urllib.error.URLError:
        # 含连接失败、DNS、超时等
        return _poll_agent_result_until_settled(
            base_url,
            request_id,
            public_key,
            secret_key,
            trigger_reason="agent-run 网络异常",
        )
    except TimeoutError:
        return _poll_agent_result_until_settled(
            base_url,
            request_id,
            public_key,
            secret_key,
            trigger_reason="agent-run 超时",
        )


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（请检查脚本内 _ENCAPSULATION_EMBEDDED 或旧版 SKILL.md）",
            file=sys.stderr,
        )
        sys.exit(1)

    public_key, secret_key = load_prana_credentials(args.base_url)
    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: config/api_key.txt
content: # config/api_key.txt — 存放 Prana Claw API 凭证（勿提交到公开 Git）
#
# 填写方式（任选其一）：
#   方式一（推荐）：下一行写 public_key:secret_key（仅一个英文冒号，无空格也可）
#   示例：pk_xxx:sk_yyy
#
#   方式二：在本文件内粘贴 GET /api/v1/api-keys 返回的完整 JSON（可多行）。
#
# 自动拉 key：运行 scripts/prana_skill_client.py 时若本地无凭证，会请求 GET /api/v1/api-keys，
#   成功后默认把结果写回本文件（首行注释 + 一行 pk:sk）。不想写盘可设 PRANA_SKILL_SKIP_WRITE_API_KEY=1。
#
# 接口查询参数说明：
#   - 必须带 target_system（脚本从 prana_skill_client.py 内 _ENCAPSULATION_EMBEDDED、SKILL.md 或环境变量读取；缺省为 prana）。
#   - 仅当 target_system=prana 时，服务端才使用 account_id；Claw Hub 等非 Prana 平台不要传 account_id。
#   - Prana / 沙盒：有 ACCOUNT_ID 或 PRANA_ACCOUNT_ID 时，脚本仅在 platform=prana 时附加 ?account_id=...
#
# 手工 curl 示例（Prana 且已有账户）：
#   curl -sS "${NEXT_PUBLIC_URL}/api/v1/api-keys?target_system=prana&account_id=${ACCOUNT_ID}"
#
# 非 Prana（例如 claw_hub，不要带 account_id）：
#   curl -sS "${NEXT_PUBLIC_URL}/api/v1/api-keys?target_system=claw_hub"
#
# 覆盖封装平台（可选环境变量，优先于脚本内嵌配置 / SKILL.md）：
#   ENCAPSULATION_TARGET 或 SKILL_ENCAPSULATION_TARGET 或 PRANA_ENCAPSULATION_TARGET


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