diff --git a/README.md b/README.md index d916856..1b1341a 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,19 @@ # template-skill -新 skill 的基础模版。 +新 skill 的基础模版,内置认证、遥测回调、CI/CD 注册流程。 + +--- ## 认证机制:auth-cli.ts -每个 skill 内置一份 `src/auth-cli.ts`,它是一个薄 wrapper,通过 subprocess 调用 `auth-rt` 二进制。 - -**不使用 npm 依赖**,auth-runtime 更新时只需重新编译二进制,不需要改动任何 skill。 - -### 工作原理 +每个 skill 内置一份 `src/auth-cli.ts`,通过 subprocess 调用 `auth-rt` 二进制完成认证,**零 npm 依赖**。 ``` -skill/src/index.ts - → import { createSkillClient } from './auth-cli.ts' - → auth-cli.ts 通过 spawnSync 调用 auth-rt 二进制 - → auth-rt 处理 token/session/request +skill/scripts/run.ts + → createSkillClient().session() + → auth-cli.ts spawnSync auth-rt + → auth-rt 读取 ~/.openclaw/.env (CLIENT_KEY) + → 返回 { accessToken, hookUrl, hookToken, ... } ``` ### 使用方式 @@ -22,44 +21,105 @@ skill/src/index.ts ```typescript import { createSkillClient } from './auth-cli.ts'; -const client = createSkillClient({ - apiBase: process.env.API_BASE, // 可选 - dryRun: false, // 可选,dry-run 模式返回模拟数据 -}); +const client = createSkillClient({ dryRun: false }); +const session = await client.session(); +// session = { accessToken, hookUrl, hookToken, ... } -// API 调用 const res = await client.post('/ecom/your/endpoint', { param: 'value' }); // res = { status: 200, body: '...' } - -// 获取 session -const session = await client.session(); -// session = { accessToken: '...', expiresIn: 900 } ``` ### 前置条件 -每台运行 skill 的机器上必须安装 `auth-rt` 二进制: +每台运行 skill 的机器需安装 `auth-rt`(`install.sh` 自动处理): ```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 -# 安装到 ~/.openclaw/bin/auth-rt +# 手动安装 +bash install.sh + +# 或确保 ~/.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 -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 自动下载) +``` diff --git a/scripts/run.ts b/scripts/run.ts index 378f6b2..118bd7e 100644 --- a/scripts/run.ts +++ b/scripts/run.ts @@ -1,6 +1,9 @@ #!/usr/bin/env bun import type { Command } 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 { console.error(`Usage: @@ -9,10 +12,20 @@ function printUsage(): void { Commands: run -Config: ~/.openclaw/.env (API_BASE) +Config: ~/.openclaw/.env (CLIENT_KEY) `); } +async function reportHook( + hookUrl: string, + hookToken: string | undefined, + payload: object, +): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + if (hookToken) headers['Authorization'] = `Bearer ${hookToken}`; + await fetch(hookUrl, { method: 'POST', headers, body: JSON.stringify(payload) }); +} + async function main(): Promise { const positionals: string[] = []; let dryRun = false; @@ -31,8 +44,51 @@ async function main(): Promise { 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>; + + 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)); + + // 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) => {