feat: 新增 Hook 遥测回调 + 更新 README
scripts/run.ts 在每次 skill 执行后自动 POST hookUrl(由 auth server 下发), 实现服务端可观测性,客户端零配置。README 补充遥测机制、新建 skill 流程说明。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8be28aab5a
commit
01fcb90505
128
README.md
128
README.md
|
|
@ -1,20 +1,19 @@
|
||||||
# template-skill
|
# template-skill
|
||||||
|
|
||||||
新 skill 的基础模版。
|
新 skill 的基础模版,内置认证、遥测回调、CI/CD 注册流程。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 认证机制:auth-cli.ts
|
## 认证机制:auth-cli.ts
|
||||||
|
|
||||||
每个 skill 内置一份 `src/auth-cli.ts`,它是一个薄 wrapper,通过 subprocess 调用 `auth-rt` 二进制。
|
每个 skill 内置一份 `src/auth-cli.ts`,通过 subprocess 调用 `auth-rt` 二进制完成认证,**零 npm 依赖**。
|
||||||
|
|
||||||
**不使用 npm 依赖**,auth-runtime 更新时只需重新编译二进制,不需要改动任何 skill。
|
|
||||||
|
|
||||||
### 工作原理
|
|
||||||
|
|
||||||
```
|
```
|
||||||
skill/src/index.ts
|
skill/scripts/run.ts
|
||||||
→ import { createSkillClient } from './auth-cli.ts'
|
→ createSkillClient().session()
|
||||||
→ auth-cli.ts 通过 spawnSync 调用 auth-rt 二进制
|
→ auth-cli.ts spawnSync auth-rt
|
||||||
→ auth-rt 处理 token/session/request
|
→ auth-rt 读取 ~/.openclaw/.env (CLIENT_KEY)
|
||||||
|
→ 返回 { accessToken, hookUrl, hookToken, ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
### 使用方式
|
### 使用方式
|
||||||
|
|
@ -22,44 +21,105 @@ skill/src/index.ts
|
||||||
```typescript
|
```typescript
|
||||||
import { createSkillClient } from './auth-cli.ts';
|
import { createSkillClient } from './auth-cli.ts';
|
||||||
|
|
||||||
const client = createSkillClient({
|
const client = createSkillClient({ dryRun: false });
|
||||||
apiBase: process.env.API_BASE, // 可选
|
const session = await client.session();
|
||||||
dryRun: false, // 可选,dry-run 模式返回模拟数据
|
// session = { accessToken, hookUrl, hookToken, ... }
|
||||||
});
|
|
||||||
|
|
||||||
// API 调用
|
|
||||||
const res = await client.post('/ecom/your/endpoint', { param: 'value' });
|
const res = await client.post('/ecom/your/endpoint', { param: 'value' });
|
||||||
// res = { status: 200, body: '...' }
|
// res = { status: 200, body: '...' }
|
||||||
|
|
||||||
// 获取 session
|
|
||||||
const session = await client.session();
|
|
||||||
// session = { accessToken: '...', expiresIn: 900 }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 前置条件
|
### 前置条件
|
||||||
|
|
||||||
每台运行 skill 的机器上必须安装 `auth-rt` 二进制:
|
每台运行 skill 的机器需安装 `auth-rt`(`install.sh` 自动处理):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone http://192.168.0.108:3030/agent-skills/auth-runtime.git ~/clawd/skills/auth-runtime
|
# 手动安装
|
||||||
cd ~/clawd/skills/auth-runtime && ./install.sh
|
bash install.sh
|
||||||
# 安装到 ~/.openclaw/bin/auth-rt
|
|
||||||
|
# 或确保 ~/.openclaw/.env 中配置
|
||||||
|
CLIENT_KEY=sk_xxxx
|
||||||
```
|
```
|
||||||
|
|
||||||
确保 `~/.openclaw/bin` 在 PATH 中,或通过 `AUTH_RT_BIN` 环境变量指定路径。
|
---
|
||||||
|
|
||||||
### auth-runtime 更新流程
|
## 遥测与可观测性:Hook 回调
|
||||||
|
|
||||||
|
`scripts/run.ts` 在每次 skill 执行后,**自动**向 auth server 下发的 `hookUrl` 发送结构化执行报告。
|
||||||
|
|
||||||
|
### 工作原理
|
||||||
|
|
||||||
|
```
|
||||||
|
Client 执行 skill
|
||||||
|
│
|
||||||
|
├─ 1. auth-rt session → auth gateway 返回 { hookUrl, hookToken }
|
||||||
|
├─ 2. 执行命令
|
||||||
|
└─ 3. POST hookUrl (fire-and-forget,不阻塞输出)
|
||||||
|
{
|
||||||
|
skill: "my-skill",
|
||||||
|
command: "run",
|
||||||
|
status: "success" | "failed",
|
||||||
|
durationMs: 1234,
|
||||||
|
error: "..." (仅失败时)
|
||||||
|
}
|
||||||
|
↓
|
||||||
|
服务端 → Loki → Grafana
|
||||||
|
```
|
||||||
|
|
||||||
|
### 特性
|
||||||
|
|
||||||
|
- **服务端控制**:`hookUrl` 由 auth server 在 session 时下发,客户端无需任何配置
|
||||||
|
- **非阻塞**:fire-and-forget,hook 失败不影响 skill 正常执行和输出
|
||||||
|
- **dry-run 跳过**:`--dry-run` 模式下不发送 hook
|
||||||
|
- **统一入口**:所有遥测逻辑在 `scripts/run.ts` 中,`src/index.ts` 保持纯业务逻辑
|
||||||
|
|
||||||
|
### 服务端可查询内容
|
||||||
|
|
||||||
|
| 维度 | Loki label / 字段 |
|
||||||
|
|------|-----------------|
|
||||||
|
| 哪个客户端调用了哪个 skill | `CLIENT_KEY` → session → hook |
|
||||||
|
| 成功率 / 失败率 | `status` |
|
||||||
|
| 响应耗时 | `durationMs` |
|
||||||
|
| 错误类型分布 | `error` |
|
||||||
|
| 命令维度拆分 | `command` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 新建 Skill 检查清单
|
||||||
|
|
||||||
|
1. 从此模版创建仓库(Forgejo → Use this template)
|
||||||
|
2. `package.json` 中修改 `name` 字段
|
||||||
|
3. `scripts/run.ts` 中修改 `SKILL_NAME` 常量
|
||||||
|
4. `SKILL.md` 中更新 `name` / `description` frontmatter
|
||||||
|
5. `src/index.ts` 中实现业务逻辑
|
||||||
|
6. **不要** 在 `package.json` 中添加 `@clawd/auth-runtime` 依赖
|
||||||
|
7. 推送 `v*` tag 触发 CI/CD 自动注册 skill
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD:自动注册
|
||||||
|
|
||||||
|
`.forgejo/workflows/register-skill-release.yml` 在推送 `v*` tag 时触发,调用 `shared-actions/register-skill` 将 skill 注册到平台。
|
||||||
|
|
||||||
auth-runtime 代码变更后:
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/clawd/skills/auth-runtime && git pull && ./install.sh
|
# 发布新版本
|
||||||
|
git tag v1.0.0 && git push origin v1.0.0
|
||||||
```
|
```
|
||||||
重新编译即可,**无需改动任何 skill 代码**。
|
|
||||||
|
|
||||||
### 新建 skill 检查清单
|
注册所需的 `CLIENT_KEY` 通过 Forgejo 仓库 Secret 配置,skill 代码中不包含任何密钥。
|
||||||
|
|
||||||
1. 从此模版创建仓库
|
---
|
||||||
2. 确认 `src/auth-cli.ts` 已包含(直接从模版继承)
|
|
||||||
3. `src/index.ts` 中 `import { createSkillClient } from './auth-cli.ts'`
|
## 目录结构
|
||||||
4. `package.json` 中 **不要** 添加 `@clawd/auth-runtime` 依赖
|
|
||||||
5. `install.sh` 中包含 auth-rt 二进制检查
|
```
|
||||||
|
.forgejo/workflows/ # CI/CD:tag 触发自动注册
|
||||||
|
scripts/run.ts # CLI 入口:参数解析 + session + 遥测 hook
|
||||||
|
src/
|
||||||
|
auth-cli.ts # auth-rt 薄 wrapper(勿修改)
|
||||||
|
index.ts # 业务逻辑入口
|
||||||
|
types.ts # 类型定义
|
||||||
|
SKILL.md # skill 元数据(frontmatter)+ Claude 使用说明
|
||||||
|
.env.example # 环境变量模版
|
||||||
|
install.sh # 依赖安装(含 auth-rt 自动下载)
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import type { Command } from '../src/index.ts';
|
import type { Command } from '../src/index.ts';
|
||||||
import { run } from '../src/index.ts';
|
import { run } from '../src/index.ts';
|
||||||
|
import { createSkillClient } from '../src/auth-cli.ts';
|
||||||
|
|
||||||
|
const SKILL_NAME = 'my-skill'; // TODO: replace with actual skill name
|
||||||
|
|
||||||
function printUsage(): void {
|
function printUsage(): void {
|
||||||
console.error(`Usage:
|
console.error(`Usage:
|
||||||
|
|
@ -9,10 +12,20 @@ function printUsage(): void {
|
||||||
Commands:
|
Commands:
|
||||||
run <arg>
|
run <arg>
|
||||||
|
|
||||||
Config: ~/.openclaw/.env (API_BASE)
|
Config: ~/.openclaw/.env (CLIENT_KEY)
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reportHook(
|
||||||
|
hookUrl: string,
|
||||||
|
hookToken: string | undefined,
|
||||||
|
payload: object,
|
||||||
|
): Promise<void> {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (hookToken) headers['Authorization'] = `Bearer ${hookToken}`;
|
||||||
|
await fetch(hookUrl, { method: 'POST', headers, body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const positionals: string[] = [];
|
const positionals: string[] = [];
|
||||||
let dryRun = false;
|
let dryRun = false;
|
||||||
|
|
@ -31,8 +44,51 @@ async function main(): Promise<void> {
|
||||||
|
|
||||||
if (positionals.length < 1) { printUsage(); process.exit(1); }
|
if (positionals.length < 1) { printUsage(); process.exit(1); }
|
||||||
|
|
||||||
const result = await run(positionals[0] as Command, positionals.slice(1), dryRun);
|
const command = positionals[0] as Command;
|
||||||
|
|
||||||
|
// Exchange CLIENT_KEY for session — gives us hookUrl for telemetry
|
||||||
|
let hookUrl: string | undefined;
|
||||||
|
let hookToken: string | undefined;
|
||||||
|
try {
|
||||||
|
const client = createSkillClient({ dryRun });
|
||||||
|
const session = await client.session();
|
||||||
|
hookUrl = session.hookUrl;
|
||||||
|
hookToken = session.hookToken;
|
||||||
|
} catch {
|
||||||
|
// Auth failure is non-fatal for telemetry; skill still runs
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMs = Date.now();
|
||||||
|
let result: Awaited<ReturnType<typeof run>>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await run(command, positionals.slice(1), dryRun);
|
||||||
|
} catch (err) {
|
||||||
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
const failed = { status: 'failed' as const, command, dryRun, error };
|
||||||
|
console.log(JSON.stringify(failed, null, 2));
|
||||||
|
|
||||||
|
if (hookUrl && !dryRun) {
|
||||||
|
reportHook(hookUrl, hookToken, {
|
||||||
|
skill: SKILL_NAME, command, status: 'failed',
|
||||||
|
durationMs: Date.now() - startMs, error,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
|
||||||
|
// Fire-and-forget telemetry — never delays output
|
||||||
|
if (hookUrl && !dryRun) {
|
||||||
|
reportHook(hookUrl, hookToken, {
|
||||||
|
skill: SKILL_NAME,
|
||||||
|
command,
|
||||||
|
status: result.status,
|
||||||
|
durationMs: Date.now() - startMs,
|
||||||
|
error: (result as any).error,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue