From febbf94f5ffcc46243e8ac7eba23a31d6c29f38c Mon Sep 17 00:00:00 2001 From: aelf-hzz780 Date: Fri, 27 Feb 2026 16:17:26 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20unify=20skill=20baseline=20?= =?UTF-8?q?for=20openclaw=20coverage=20docs=20and=20codex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/coverage-badge.yml | 33 ++++++ .github/workflows/publish.yml | 4 +- .github/workflows/test.yml | 16 ++- README.md | 40 +++++++ README.zh-CN.md | 40 +++++++ SKILL.md | 32 ++++++ bin/generate-coverage-badge.ts | 44 ++++++++ bin/generate-openclaw.ts | 163 +++++++++++++++++++++++++++ openclaw.json | 143 +++++++++++++++++++---- package.json | 7 +- scripts/coverage-gate.ts | 109 ++++++++++++++++++ 11 files changed, 601 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/coverage-badge.yml create mode 100644 SKILL.md create mode 100644 bin/generate-coverage-badge.ts create mode 100644 bin/generate-openclaw.ts create mode 100644 scripts/coverage-gate.ts diff --git a/.github/workflows/coverage-badge.yml b/.github/workflows/coverage-badge.yml new file mode 100644 index 0000000..a55972a --- /dev/null +++ b/.github/workflows/coverage-badge.yml @@ -0,0 +1,33 @@ +name: Coverage Badge + +on: + push: + branches: + - main + - master + workflow_dispatch: + +permissions: + contents: write + +jobs: + coverage-badge: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - run: bun install + - run: bun run test:coverage:ci + - run: bun run coverage:badge + - run: touch badge/.nojekyll + + - name: Deploy coverage badge to gh-pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./badge + publish_branch: gh-pages diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4029eb6..3fcd887 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,8 +20,8 @@ jobs: bun-version: latest - run: bun install - - - run: bun run test:unit + - run: bun run test:coverage:ci + - run: bun run build:openclaw:check publish: if: startsWith(github.ref, 'refs/tags/v') diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c29334a..74ad18a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,8 @@ permissions: jobs: unit-test: runs-on: ubuntu-latest + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} steps: - uses: actions/checkout@v4 @@ -21,13 +23,17 @@ jobs: bun-version: latest - run: bun install - - - run: bun run test:unit:coverage:gate - env: - CORE_COVERAGE_THRESHOLD: '80' + - run: bun run test:coverage:ci + - run: bun run build:openclaw:check - name: Upload coverage to Codecov + if: ${{ env.CODECOV_TOKEN != '' }} uses: codecov/codecov-action@v5 with: + token: ${{ env.CODECOV_TOKEN }} files: ./coverage/lcov.info - fail_ci_if_error: false + fail_ci_if_error: true + + - name: Skip Codecov upload (missing token) + if: ${{ env.CODECOV_TOKEN == '' }} + run: echo "CODECOV_TOKEN is not set; skipping Codecov upload." diff --git a/README.md b/README.md index 522b5c2..19fdfe3 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ [English](./README.md) | [中文](./README.zh-CN.md) +[![Unit Tests](https://github.com/AElfProject/aelf-node-skill/actions/workflows/test.yml/badge.svg)](https://github.com/AElfProject/aelf-node-skill/actions/workflows/test.yml) +[![Coverage](https://img.shields.io/endpoint?url=https://AElfProject.github.io/aelf-node-skill/coverage.json)](https://AElfProject.github.io/aelf-node-skill/coverage.json) + AElf Node Skill provides MCP, CLI, and SDK interfaces for AElf public nodes with `REST for reads, SDK for contract execution, and selective fallback for fee estimate`. ## Features @@ -23,6 +26,27 @@ AElf Node Skill provides MCP, CLI, and SDK interfaces for AElf public nodes with bun install ``` +## Usage + +### MCP + +```bash +bun run mcp +``` + +### CLI + +```bash +bun run cli get-chain-status --chain-id AELF +``` + +### OpenClaw + +```bash +bun run build:openclaw +bun run build:openclaw:check +``` + ## Quick Start ```bash @@ -118,3 +142,19 @@ MCP tool names: - `src/mcp/server.ts`: MCP adapter - `aelf_node_skill.ts`: CLI adapter - `index.ts`: SDK exports + +## Testing + +```bash +bun run test +bun run test:coverage:ci +``` + +## Security + +- Never put `AELF_PRIVATE_KEY` in prompts or channel outputs. +- Use environment variables for all secrets. + +## License + +MIT diff --git a/README.zh-CN.md b/README.zh-CN.md index 31aa085..09a069a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,6 +2,9 @@ [中文](./README.zh-CN.md) | [English](./README.md) +[![Unit Tests](https://github.com/AElfProject/aelf-node-skill/actions/workflows/test.yml/badge.svg)](https://github.com/AElfProject/aelf-node-skill/actions/workflows/test.yml) +[![Coverage](https://img.shields.io/endpoint?url=https://AElfProject.github.io/aelf-node-skill/coverage.json)](https://AElfProject.github.io/aelf-node-skill/coverage.json) + AElf Node Skill 提供 MCP、CLI、SDK 三种接口,采用“读走 REST、合约执行走 SDK、手续费估算选择性 fallback”的架构访问 AElf 公共节点。 ## 功能 @@ -23,6 +26,27 @@ AElf Node Skill 提供 MCP、CLI、SDK 三种接口,采用“读走 REST、合 bun install ``` +## 使用方式 + +### MCP + +```bash +bun run mcp +``` + +### CLI + +```bash +bun run cli get-chain-status --chain-id AELF +``` + +### OpenClaw + +```bash +bun run build:openclaw +bun run build:openclaw:check +``` + ## 快速开始 ```bash @@ -118,3 +142,19 @@ MCP tool 名称: - `src/mcp/server.ts`:MCP 入口 - `aelf_node_skill.ts`:CLI 入口 - `index.ts`:SDK 导出 + +## 测试 + +```bash +bun run test +bun run test:coverage:ci +``` + +## 安全 + +- 不要在对话输出中暴露 `AELF_PRIVATE_KEY`。 +- 所有密钥均通过环境变量管理。 + +## License + +MIT diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..b46ec32 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,32 @@ +--- +name: "aelf-node-skill" +description: "AElf node querying and contract execution skill for agents." +--- + +# AElf Node Skill + +## When to use +- Use this skill when you need AElf chain query, contract view/send, and fee estimation tasks. + +## Capabilities +- Chain reads: status, block, transaction result, metadata +- Contract operations: view call and transaction sending +- Node registry import/list with REST-first and SDK fallback strategy +- Supports SDK, CLI, MCP, and OpenClaw integration from one codebase. + +## Safe usage rules +- Never print private keys, mnemonics, or tokens in channel outputs. +- For write operations, require explicit user confirmation and validate parameters before sending transactions. +- Prefer `simulate` or read-only queries first when available. + +## Command recipes +- Start MCP server: `bun run mcp` +- Run CLI entry: `bun run cli` +- Generate OpenClaw config: `bun run build:openclaw` +- Verify OpenClaw config: `bun run build:openclaw:check` +- Run CI coverage gate: `bun run test:coverage:ci` + +## Limits / Non-goals +- This skill focuses on domain operations and adapters; it is not a full wallet custody system. +- Do not hardcode environment secrets in source code or docs. +- Avoid bypassing validation for external service calls. diff --git a/bin/generate-coverage-badge.ts b/bin/generate-coverage-badge.ts new file mode 100644 index 0000000..a742940 --- /dev/null +++ b/bin/generate-coverage-badge.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env bun +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const root = path.resolve(import.meta.dir, '..'); +const lcovPath = path.join(root, 'coverage', 'lcov.info'); +const outputDir = path.join(root, 'badge'); +const outputPath = path.join(outputDir, 'coverage.json'); + +if (!fs.existsSync(lcovPath)) { + process.stderr.write(`[ERROR] lcov not found: ${lcovPath}\n`); + process.exit(1); +} + +const lcov = fs.readFileSync(lcovPath, 'utf-8'); +let linesFound = 0; +let linesHit = 0; + +for (const line of lcov.split('\n')) { + if (line.startsWith('LF:')) linesFound += Number(line.slice(3)) || 0; + if (line.startsWith('LH:')) linesHit += Number(line.slice(3)) || 0; +} + +const coverage = linesFound > 0 ? (linesHit / linesFound) * 100 : 0; +const rounded = Math.round(coverage * 100) / 100; + +let color = 'red'; +if (rounded >= 90) color = 'brightgreen'; +else if (rounded >= 80) color = 'green'; +else if (rounded >= 70) color = 'yellowgreen'; +else if (rounded >= 60) color = 'yellow'; +else if (rounded >= 50) color = 'orange'; + +const badge = { + schemaVersion: 1, + label: 'coverage', + message: `${rounded}%`, + color, + generatedAt: new Date().toISOString(), +}; + +fs.mkdirSync(outputDir, { recursive: true }); +fs.writeFileSync(outputPath, `${JSON.stringify(badge, null, 2)}\n`, 'utf-8'); +process.stdout.write(`[OK] Coverage badge generated: ${outputPath} (${badge.message})\n`); diff --git a/bin/generate-openclaw.ts b/bin/generate-openclaw.ts new file mode 100644 index 0000000..91a6e81 --- /dev/null +++ b/bin/generate-openclaw.ts @@ -0,0 +1,163 @@ +#!/usr/bin/env bun +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +type JsonObject = { [key: string]: JsonValue }; + +type OpenClawParameter = { + type?: string; + required?: boolean; + description?: string; + default?: JsonValue; +}; + +type OpenClawTool = { + name: string; + description: string; + command: string; + args: string[]; + cwd: string; + inputSchema: JsonObject; +}; + +function readJson(filePath: string): JsonObject { + const content = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(content) as JsonObject; +} + +function buildSchemaFromParameters(parameters: JsonObject | undefined): JsonObject | undefined { + if (!parameters) return undefined; + + const properties: Record = {}; + const required: string[] = []; + + for (const [key, raw] of Object.entries(parameters)) { + const param = (raw || {}) as OpenClawParameter; + const prop: JsonObject = { + type: typeof param.type === 'string' ? param.type : 'string', + description: + typeof param.description === 'string' + ? param.description + : `${key} parameter`, + }; + + if (param.default !== undefined) { + prop.default = param.default; + } + + properties[key] = prop; + + if (param.required) { + required.push(key); + } + } + + const schema: JsonObject = { + type: 'object', + properties, + additionalProperties: true, + }; + + if (required.length > 0) { + schema.required = required; + } + + return schema; +} + +function normalizeCommand(tool: JsonObject): { command: string; args: string[] } { + const rawCommand = tool.command; + const rawArgs = tool.args; + + if (typeof rawCommand !== 'string' || rawCommand.trim().length === 0) { + throw new Error(`Tool ${String(tool.name)} missing command`); + } + + if (Array.isArray(rawArgs)) { + return { + command: rawCommand, + args: rawArgs.map((v) => String(v)), + }; + } + + if (rawCommand.includes(' ')) { + return { + command: 'sh', + args: ['-lc', rawCommand], + }; + } + + return { + command: rawCommand, + args: [], + }; +} + +function normalizeTool(rawTool: JsonObject): OpenClawTool { + const { command, args } = normalizeCommand(rawTool); + const inputSchema = + (rawTool.inputSchema as JsonObject | undefined) || + buildSchemaFromParameters(rawTool.parameters as JsonObject | undefined) || { + type: 'object', + properties: {}, + additionalProperties: true, + }; + + return { + name: String(rawTool.name || ''), + description: String(rawTool.description || ''), + command, + args, + cwd: String(rawTool.cwd || rawTool.working_directory || '.'), + inputSchema, + }; +} + +function normalizeOpenclaw(raw: JsonObject, pkg: JsonObject): JsonObject { + const rawTools = Array.isArray(raw.tools) + ? (raw.tools as JsonObject[]) + : Array.isArray(raw.skills) + ? (raw.skills as JsonObject[]) + : []; + + if (rawTools.length === 0) { + throw new Error('No tools/skills found in openclaw.json'); + } + + const tools = rawTools.map(normalizeTool); + + return { + name: typeof raw.name === 'string' ? raw.name : String(pkg.name || 'skill-openclaw'), + description: + typeof raw.description === 'string' + ? raw.description + : String(pkg.description || 'OpenClaw tool config'), + tools, + }; +} + +const packageRoot = path.resolve(import.meta.dir, '..'); +const targetPath = path.join(packageRoot, 'openclaw.json'); +const packageJsonPath = path.join(packageRoot, 'package.json'); + +const raw = readJson(targetPath); +const pkg = readJson(packageJsonPath); +const normalized = normalizeOpenclaw(raw, pkg); +const serialized = `${JSON.stringify(normalized, null, 2)}\n`; +const checkMode = process.argv.includes('--check'); + +if (checkMode) { + const existing = fs.readFileSync(targetPath, 'utf8'); + if (existing !== serialized) { + process.stderr.write('[ERROR] openclaw.json is out of date. Run `bun run build:openclaw`\n'); + process.exit(1); + } + + process.stdout.write('[OK] openclaw.json is up to date\n'); + process.exit(0); +} + +fs.writeFileSync(targetPath, serialized, 'utf8'); +process.stdout.write(`[OK] Generated ${targetPath} with ${(normalized.tools as JsonObject[]).length} tools\n`); diff --git a/openclaw.json b/openclaw.json index 7ce13c7..5a5c258 100644 --- a/openclaw.json +++ b/openclaw.json @@ -6,78 +6,177 @@ "name": "aelf-get-chain-status", "description": "Get chain status from a node", "command": "bun", - "args": ["run", "aelf_node_skill.ts", "get-chain-status"], - "cwd": "." + "args": [ + "run", + "aelf_node_skill.ts", + "get-chain-status" + ], + "cwd": ".", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } }, { "name": "aelf-get-block-height", "description": "Get latest block height from a node", "command": "bun", - "args": ["run", "aelf_node_skill.ts", "get-block-height"], - "cwd": "." + "args": [ + "run", + "aelf_node_skill.ts", + "get-block-height" + ], + "cwd": ".", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } }, { "name": "aelf-get-block", "description": "Get block detail by block hash", "command": "bun", - "args": ["run", "aelf_node_skill.ts", "get-block"], - "cwd": "." + "args": [ + "run", + "aelf_node_skill.ts", + "get-block" + ], + "cwd": ".", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } }, { "name": "aelf-get-transaction-result", "description": "Get transaction result by transaction id", "command": "bun", - "args": ["run", "aelf_node_skill.ts", "get-transaction-result"], - "cwd": "." + "args": [ + "run", + "aelf_node_skill.ts", + "get-transaction-result" + ], + "cwd": ".", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } }, { "name": "aelf-get-contract-view-methods", "description": "List contract view methods via REST adapter", "command": "bun", - "args": ["run", "aelf_node_skill.ts", "get-contract-view-methods"], - "cwd": "." + "args": [ + "run", + "aelf_node_skill.ts", + "get-contract-view-methods" + ], + "cwd": ".", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } }, { "name": "aelf-get-system-contract-address", "description": "Get system contract address by name", "command": "bun", - "args": ["run", "aelf_node_skill.ts", "get-system-contract-address"], - "cwd": "." + "args": [ + "run", + "aelf_node_skill.ts", + "get-system-contract-address" + ], + "cwd": ".", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } }, { "name": "aelf-call-contract-view", "description": "Call a contract view method via aelf-sdk", "command": "bun", - "args": ["run", "aelf_node_skill.ts", "call-contract-view"], - "cwd": "." + "args": [ + "run", + "aelf_node_skill.ts", + "call-contract-view" + ], + "cwd": ".", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } }, { "name": "aelf-send-contract-transaction", "description": "Send a contract transaction via aelf-sdk", "command": "bun", - "args": ["run", "aelf_node_skill.ts", "send-contract-transaction"], - "cwd": "." + "args": [ + "run", + "aelf_node_skill.ts", + "send-contract-transaction" + ], + "cwd": ".", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } }, { "name": "aelf-estimate-transaction-fee", "description": "Estimate transaction fee via REST + SDK fallback", "command": "bun", - "args": ["run", "aelf_node_skill.ts", "estimate-transaction-fee"], - "cwd": "." + "args": [ + "run", + "aelf_node_skill.ts", + "estimate-transaction-fee" + ], + "cwd": ".", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } }, { "name": "aelf-import-node", "description": "Import or update a custom node profile", "command": "bun", - "args": ["run", "aelf_node_skill.ts", "import-node"], - "cwd": "." + "args": [ + "run", + "aelf_node_skill.ts", + "import-node" + ], + "cwd": ".", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } }, { "name": "aelf-list-nodes", "description": "List imported and available nodes", "command": "bun", - "args": ["run", "aelf_node_skill.ts", "list-nodes"], - "cwd": "." + "args": [ + "run", + "aelf_node_skill.ts", + "list-nodes" + ], + "cwd": ".", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } } ] } diff --git a/package.json b/package.json index 82fad86..8da9340 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,12 @@ "coverage:core": "bun run bin/check-core-coverage.ts", "test:unit:coverage:gate": "bun run test:unit:coverage && bun run coverage:core", "test:integration": "bun test tests/integration/", - "test:live": "RUN_LIVE_TESTS=1 bun test tests/integration/" + "test:live": "RUN_LIVE_TESTS=1 bun test tests/integration/", + "coverage:gate": "bun run scripts/coverage-gate.ts", + "test:coverage:ci": "COVERAGE_MIN_LINES=85 COVERAGE_MIN_FUNCS=80 bun run test:unit:coverage && bun run coverage:gate", + "build:openclaw": "bun run bin/generate-openclaw.ts", + "build:openclaw:check": "bun run bin/generate-openclaw.ts --check", + "coverage:badge": "bun run bin/generate-coverage-badge.ts" }, "keywords": [ "aelf", diff --git a/scripts/coverage-gate.ts b/scripts/coverage-gate.ts new file mode 100644 index 0000000..47f0853 --- /dev/null +++ b/scripts/coverage-gate.ts @@ -0,0 +1,109 @@ +#!/usr/bin/env bun +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +type SectionTotals = { + linesFound: number; + linesHit: number; + funcsFound: number; + funcsHit: number; +}; + +function isSrcFile(sfPath: string): boolean { + const normalized = sfPath.replace(/\\/g, '/'); + return normalized.startsWith('src/') || normalized.includes('/src/'); +} + +function parseLcov(lcovText: string): SectionTotals { + const totals: SectionTotals = { + linesFound: 0, + linesHit: 0, + funcsFound: 0, + funcsHit: 0, + }; + + let currentFile = ''; + + for (const rawLine of lcovText.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + + if (line.startsWith('SF:')) { + currentFile = line.slice(3); + continue; + } + + if (!currentFile || !isSrcFile(currentFile)) { + continue; + } + + if (line.startsWith('LF:')) { + totals.linesFound += Number(line.slice(3)) || 0; + continue; + } + + if (line.startsWith('LH:')) { + totals.linesHit += Number(line.slice(3)) || 0; + continue; + } + + if (line.startsWith('FNF:')) { + totals.funcsFound += Number(line.slice(4)) || 0; + continue; + } + + if (line.startsWith('FNH:')) { + totals.funcsHit += Number(line.slice(4)) || 0; + continue; + } + } + + return totals; +} + +function percent(hit: number, found: number): number { + if (found <= 0) return 0; + return (hit / found) * 100; +} + +function main() { + const minLines = Number(process.env.COVERAGE_MIN_LINES || '85'); + const minFuncs = Number(process.env.COVERAGE_MIN_FUNCS || '80'); + const lcovFile = process.env.COVERAGE_LCOV_FILE || 'coverage/lcov.info'; + const lcovPath = resolve(process.cwd(), lcovFile); + + if (!existsSync(lcovPath)) { + console.error(`[coverage-gate] lcov file not found: ${lcovPath}`); + process.exit(1); + } + + const lcov = readFileSync(lcovPath, 'utf8'); + const totals = parseLcov(lcov); + + if (totals.linesFound === 0 || totals.funcsFound === 0) { + console.error('[coverage-gate] no src/** lines/functions coverage data found'); + process.exit(1); + } + + const linePct = percent(totals.linesHit, totals.linesFound); + const funcPct = percent(totals.funcsHit, totals.funcsFound); + + const failures: string[] = []; + if (linePct < minLines) { + failures.push(`lines ${linePct.toFixed(2)}% < ${minLines}%`); + } + if (funcPct < minFuncs) { + failures.push(`funcs ${funcPct.toFixed(2)}% < ${minFuncs}%`); + } + + if (failures.length) { + console.error(`[coverage-gate] failed: ${failures.join(', ')}`); + process.exit(1); + } + + console.log( + `[coverage-gate] passed: lines=${linePct.toFixed(2)}% funcs=${funcPct.toFixed(2)}% (threshold lines>=${minLines} funcs>=${minFuncs})`, + ); +} + +main();