feat: 添加 claw-market CLI 工具并更新 skill 使用 CLI

- 创建 pnpm monorepo 结构 (pnpm-workspace.yaml)
- 添加 @ricardweii/claw-market CLI 包
  - register/heartbeat/task/config 命令
  - 中英文国际化支持
  - JSON 输出格式支持
- 更新 openclaw-reporter skill 使用 CLI 替代 curl
- 修复注册 API 返回缺少 name 字段的问题
- 更新 CLAUDE.md 文档说明 monorepo 结构
This commit is contained in:
richarjiang
2026-03-15 13:59:57 +08:00
parent 48ac785290
commit 7db59c9290
24 changed files with 2968 additions and 63 deletions

View File

@@ -4,10 +4,33 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
OpenClaw Market is a real-time global heatmap dashboard that visualizes AI agent ("claw") activity worldwide. Agents install the `openclaw-reporter` skill, which sends anonymous heartbeats and task summaries to this server. The frontend renders a 3D globe and dashboard panels showing live activity via SSE. OpenClaw Market is a real-time global heatmap dashboard that visualizes AI agent ("claw") activity worldwide. Agents install the `openclaw-reporter` skill or `@ricardweii/claw-market` CLI, which sends anonymous heartbeats and task summaries to this server. The frontend renders a 3D globe and dashboard panels showing live activity via SSE.
## Monorepo Structure
This is a pnpm monorepo with two packages:
```
openclaw-market/
├── app/ # Next.js application (main web app)
├── lib/ # Shared libraries for web app
├── packages/
│ └── claw-market/ # CLI tool (published to npm)
│ ├── src/
│ │ ├── index.ts # CLI entry point
│ │ ├── commands/ # register, heartbeat, task, config
│ │ ├── lib/ # api, config, validate, platform
│ │ └── i18n/ # en.json, zh.json
│ └── package.json
├── skill/
│ └── openclaw-reporter/ # Claude Code skill (uses CLI)
└── pnpm-workspace.yaml
```
## Commands ## Commands
### Main App
```bash ```bash
pnpm dev # Start dev server with Turbopack (localhost:3000) pnpm dev # Start dev server with Turbopack (localhost:3000)
pnpm build # Production build pnpm build # Production build
@@ -19,11 +42,26 @@ pnpm db:studio # Open Drizzle Studio GUI
bash scripts/deploy.sh # Build locally, rsync to server, restart PM2 bash scripts/deploy.sh # Build locally, rsync to server, restart PM2
``` ```
### CLI Package
```bash
pnpm --filter claw-market build # Build CLI
pnpm --filter claw-market lint # Type check CLI
pnpm --filter claw-market dev # Build with watch mode
# Or from packages/claw-market:
cd packages/claw-market
pnpm build
node dist/index.js --help
```
## Architecture ## Architecture
### Data Flow ### Data Flow
1. **Reporter skill** (`skill/openclaw-reporter/SKILL.md`) runs inside Claude Code sessions on user machines. It calls `/api/v1/register` once, then sends periodic heartbeats and task reports via Bearer token auth. 1. **Reporter sources**:
- **CLI** (`packages/claw-market/`) — Standalone npm package `@ricardweii/claw-market`
- **Skill** (`skill/openclaw-reporter/SKILL.md`) — Claude Code skill that wraps the CLI
2. **API routes** (`app/api/v1/`) validate requests, update MySQL via Drizzle, cache state in Redis, and publish events to a Redis Pub/Sub channel (`channel:realtime`). 2. **API routes** (`app/api/v1/`) validate requests, update MySQL via Drizzle, cache state in Redis, and publish events to a Redis Pub/Sub channel (`channel:realtime`).
3. **SSE endpoint** (`/api/v1/stream`) subscribes to Redis Pub/Sub and streams events to the browser. 3. **SSE endpoint** (`/api/v1/stream`) subscribes to Redis Pub/Sub and streams events to the browser.
4. **Frontend** is a single-page "use client" app. The homepage renders a 3D globe (`react-globe.gl`), dashboard panels, and a continent drill-down page. Data arrives via polling (`use-heatmap-data`) and SSE (`use-sse`). 4. **Frontend** is a single-page "use client" app. The homepage renders a 3D globe (`react-globe.gl`), dashboard panels, and a continent drill-down page. Data arrives via polling (`use-heatmap-data`) and SSE (`use-sse`).
@@ -33,7 +71,7 @@ bash scripts/deploy.sh # Build locally, rsync to server, restart PM2
- **Auth**: Bearer token via `lib/auth/api-key.ts`. API keys are generated at registration, cached in Redis for 1 hour. - **Auth**: Bearer token via `lib/auth/api-key.ts`. API keys are generated at registration, cached in Redis for 1 hour.
- **Geo**: IP geolocation via `ip-api.com`, results cached in `geo_cache` MySQL table. Country-to-continent mapping in `lib/geo/ip-location.ts`. - **Geo**: IP geolocation via `ip-api.com`, results cached in `geo_cache` MySQL table. Country-to-continent mapping in `lib/geo/ip-location.ts`.
- **Real-time**: Redis Pub/Sub (`lib/redis/index.ts`) for event broadcasting. SSE stream route creates a per-connection Redis subscriber. - **Real-time**: Redis Pub/Sub (`lib/redis/index.ts`) for event broadcasting. SSE stream route creates a per-connection Redis subscriber.
- **Validation**: Zod schemas in `lib/validators/schemas.ts`. - **Validation**: Zod schemas in `lib/validators/schemas.ts`. Shared with CLI via copy (same structure).
- **Database**: Drizzle ORM with MySQL (`mysql2` driver). Schema in `lib/db/schema.ts`. Tables: `claws`, `heartbeats`, `tasks`, `geo_cache`. - **Database**: Drizzle ORM with MySQL (`mysql2` driver). Schema in `lib/db/schema.ts`. Tables: `claws`, `heartbeats`, `tasks`, `geo_cache`.
- **Redis**: ioredis with two singleton clients (main + subscriber). Stores online status, active claw sorted sets, global/region stats, hourly activity, heatmap cache. - **Redis**: ioredis with two singleton clients (main + subscriber). Stores online status, active claw sorted sets, global/region stats, hourly activity, heatmap cache.
- **i18n**: `next-intl` with locale-prefixed routing (`/en/...`, `/zh/...`). Config in `i18n/routing.ts`, middleware in `middleware.ts`, translations in `messages/en.json` and `messages/zh.json`. - **i18n**: `next-intl` with locale-prefixed routing (`/en/...`, `/zh/...`). Config in `i18n/routing.ts`, middleware in `middleware.ts`, translations in `messages/en.json` and `messages/zh.json`.
@@ -48,6 +86,16 @@ bash scripts/deploy.sh # Build locally, rsync to server, restart PM2
- `components/layout/` — Navbar, particle background, view switcher, install banner, language switcher - `components/layout/` — Navbar, particle background, view switcher, install banner, language switcher
- `messages/` — i18n translation files (en, zh) - `messages/` — i18n translation files (en, zh)
### CLI Structure (`packages/claw-market/`)
- `src/index.ts` — CLI entry point (commander)
- `src/commands/` — Command implementations (register, heartbeat, task, config)
- `src/lib/api.ts` — HTTP client for API calls
- `src/lib/config.ts` — Config file management (~/.openclaw/config.json)
- `src/lib/validate.ts` — Zod validation schemas
- `src/lib/platform.ts` — Platform/model detection
- `src/i18n/` — Translation files (en, zh)
## Environment Variables ## Environment Variables
See `.env.example`: `DATABASE_URL` (MySQL), `REDIS_URL`, `IP_API_URL`, `NEXT_PUBLIC_APP_URL`. See `.env.example`: `DATABASE_URL` (MySQL), `REDIS_URL`, `IP_API_URL`, `NEXT_PUBLIC_APP_URL`.
@@ -55,3 +103,18 @@ See `.env.example`: `DATABASE_URL` (MySQL), `REDIS_URL`, `IP_API_URL`, `NEXT_PUB
## Deployment ## Deployment
Production runs on a remote server via `scripts/deploy.sh` — builds locally, rsyncs to server, installs prod deps, restarts via PM2 on port 3003. Production runs on a remote server via `scripts/deploy.sh` — builds locally, rsyncs to server, installs prod deps, restarts via PM2 on port 3003.
## CLI Publishing
The CLI is published to npm as `@ricardweii/claw-market`:
```bash
cd packages/claw-market
npm publish --access public --otp=<OTP>
```
Install globally:
```bash
npm install -g @ricardweii/claw-market
```

View File

@@ -77,6 +77,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ return NextResponse.json({
clawId, clawId,
apiKey, apiKey,
name,
endpoint: `${process.env.NEXT_PUBLIC_APP_URL}/api/v1`, endpoint: `${process.env.NEXT_PUBLIC_APP_URL}/api/v1`,
}); });
} catch (error) { } catch (error) {

View File

@@ -2,6 +2,9 @@
"name": "openclaw-market", "name": "openclaw-market",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"workspaces": [
"packages/*"
],
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",

4
packages/claw-market/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.DS_Store

View File

@@ -0,0 +1,163 @@
# @ricardweii/claw-market
CLI tool for OpenClaw Market - report AI agent activity to the global heatmap.
## Installation
```bash
npm install -g @ricardweii/claw-market
# or
pnpm add -g @ricardweii/claw-market
```
## Quick Start
```bash
# Register your claw
claw-market register MyClaw
# Send a heartbeat
claw-market heartbeat
# Report a completed task
claw-market task "Fixed a bug" --duration 45000
```
## Commands
### `register`
Register a new claw on the heatmap.
```bash
claw-market register <name> [options]
```
**Arguments:**
- `name` - Claw display name (1-100 characters)
**Options:**
- `-p, --platform <string>` - Platform identifier (default: auto-detect)
- `-m, --model <string>` - Model identifier (default: from env or 'unknown')
- `-f, --force` - Force re-registration even if already registered
**Examples:**
```bash
claw-market register CoolClaw
claw-market register "NightCrawler" --model claude-opus-4
```
### `heartbeat`
Send a heartbeat to update online status.
```bash
claw-market heartbeat [options]
```
**Options:**
- `-n, --name <string>` - Update claw name
- `-m, --model <string>` - Update model identifier
- `-p, --platform <string>` - Update platform identifier
**Examples:**
```bash
claw-market heartbeat
claw-market heartbeat --model claude-sonnet-4
```
### `task`
Report a completed task.
```bash
claw-market task <summary> --duration <ms> [options]
```
**Arguments:**
- `summary` - Task summary (max 500 characters)
**Options:**
- `-d, --duration <ms>` - Task duration in milliseconds (required)
- `-m, --model <string>` - Model used for the task
- `-t, --tools <tools...>` - Tools used (space-separated)
**Examples:**
```bash
claw-market task "Fixed bug" --duration 45000
claw-market task "Refactored API" --duration 120000 --tools Bash Read Write
```
### `config`
Manage CLI configuration.
```bash
claw-market config <action>
```
**Actions:**
- `show` - Show current configuration
- `set <key> <value>` - Set a configuration value
- `path` - Show configuration file path
- `clear` - Delete configuration (unregister)
**Examples:**
```bash
claw-market config show
claw-market config set endpoint https://custom.server/api/v1
claw-market config set lang zh
claw-market config clear
```
## Global Options
```bash
claw-market [command] [options]
Options:
-e, --endpoint <url> API endpoint (default: https://kymr.top/api/v1)
-l, --lang <locale> Output language (en/zh)
--json Output in JSON format
--version Show version
--help Show help
```
## Configuration
Configuration is stored at `~/.openclaw/config.json` with file permissions `600` (owner only).
**Configuration structure:**
```json
{
"clawId": "abc123...",
"apiKey": "a1b2c3...",
"name": "MyClaw",
"endpoint": "https://kymr.top/api/v1",
"lang": "en"
}
```
## Environment Variables
| Variable | Description |
|----------|-------------|
| `CLAUDE_MODEL` | Default model identifier |
| `OPENCLAW_LANG` | Default output language (en/zh) |
## Data Disclosure
This CLI sends the following data to the OpenClaw Market server:
| Data Field | Example | Purpose |
|---|---|---|
| Claw Name | `CoolClaw` | Your display name on the heatmap |
| Platform | `darwin`, `linux` | OS type for heatmap stats |
| Model | `claude-sonnet-4-6` | Model usage stats |
| Task summary | `"Completed a task"` | Generic activity indicator |
**Never sent:** system usernames, file paths, code snippets, project names, or secrets.
## License
MIT

View File

@@ -0,0 +1,163 @@
# @ricardweii/claw-market
OpenClaw Market 命令行工具 - 向全球热力图报告 AI agent 活动。
## 安装
```bash
npm install -g @ricardweii/claw-market
# 或
pnpm add -g @ricardweii/claw-market
```
## 快速开始
```bash
# 注册你的 claw
claw-market register 我的龙虾
# 发送心跳
claw-market heartbeat
# 报告已完成的任务
claw-market task "修复了一个 bug" --duration 45000
```
## 命令
### `register`
在热力图上注册一个新的 claw。
```bash
claw-market register <name> [options]
```
**参数:**
- `name` - Claw 显示名称 (1-100 字符)
**选项:**
- `-p, --platform <string>` - 平台标识 (默认: 自动检测)
- `-m, --model <string>` - 模型标识 (默认: 从环境变量或 'unknown')
- `-f, --force` - 强制重新注册 (即使已注册)
**示例:**
```bash
claw-market register 酷龙虾
claw-market register "暗夜龙虾" --model claude-opus-4
```
### `heartbeat`
发送心跳以更新在线状态。
```bash
claw-market heartbeat [options]
```
**选项:**
- `-n, --name <string>` - 更新 claw 名称
- `-m, --model <string>` - 更新模型标识
- `-p, --platform <string>` - 更新平台标识
**示例:**
```bash
claw-market heartbeat
claw-market heartbeat --model claude-sonnet-4
```
### `task`
报告已完成的任务。
```bash
claw-market task <summary> --duration <ms> [options]
```
**参数:**
- `summary` - 任务摘要 (最多 500 字符)
**选项:**
- `-d, --duration <ms>` - 任务时长 (毫秒,必填)
- `-m, --model <string>` - 任务使用的模型
- `-t, --tools <tools...>` - 使用的工具 (空格分隔)
**示例:**
```bash
claw-market task "修复 bug" --duration 45000
claw-market task "重构 API" --duration 120000 --tools Bash Read Write
```
### `config`
管理 CLI 配置。
```bash
claw-market config <action>
```
**操作:**
- `show` - 显示当前配置
- `set <key> <value>` - 设置配置值
- `path` - 显示配置文件路径
- `clear` - 清除配置 (注销)
**示例:**
```bash
claw-market config show
claw-market config set endpoint https://custom.server/api/v1
claw-market config set lang zh
claw-market config clear
```
## 全局选项
```bash
claw-market [command] [options]
Options:
-e, --endpoint <url> API 端点 (默认: https://kymr.top/api/v1)
-l, --lang <locale> 输出语言 (en/zh)
--json 以 JSON 格式输出
--version 显示版本
--help 显示帮助
```
## 配置
配置存储在 `~/.openclaw/config.json`,文件权限为 `600` (仅所有者可读写)。
**配置结构:**
```json
{
"clawId": "abc123...",
"apiKey": "a1b2c3...",
"name": "我的龙虾",
"endpoint": "https://kymr.top/api/v1",
"lang": "zh"
}
```
## 环境变量
| 变量 | 描述 |
|------|------|
| `CLAUDE_MODEL` | 默认模型标识 |
| `OPENCLAW_LANG` | 默认输出语言 (en/zh) |
## 数据披露
此 CLI 向 OpenClaw Market 服务器发送以下数据:
| 数据字段 | 示例 | 用途 |
|---|---|---|
| Claw 名称 | `酷龙虾` | 热力图上的显示名称 |
| 平台 | `darwin`, `linux` | 热力图统计的操作系统类型 |
| 模型 | `claude-sonnet-4-6` | 模型使用统计 |
| 任务摘要 | `"完成了一个任务"` | 通用活动指标 |
**永不发送:** 系统用户名、文件路径、代码片段、项目名称或密钥。
## 许可证
MIT

View File

@@ -0,0 +1,62 @@
{
"name": "@ricardweii/claw-market",
"version": "0.1.1",
"description": "CLI tool for OpenClaw Market - report AI agent activity to the global heatmap",
"type": "module",
"bin": {
"claw-market": "dist/index.js"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist",
"README.md",
"README.zh.md"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",
"test:watch": "vitest",
"lint": "tsc --noEmit",
"prepublishOnly": "pnpm build"
},
"keywords": [
"cli",
"ai",
"agent",
"heatmap",
"openclaw",
"telemetry"
],
"author": "OpenClaw",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/openclaw/openclaw-market.git",
"directory": "packages/claw-market"
},
"bugs": {
"url": "https://github.com/openclaw/openclaw-market/issues"
},
"homepage": "https://kymr.top",
"dependencies": {
"commander": "^13.1.0",
"zod": "^3.24.3"
},
"devDependencies": {
"@types/node": "^22.13.0",
"tsup": "^8.3.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -0,0 +1,117 @@
import type { Command } from "commander";
import {
readConfig,
getConfigPath,
clearConfig,
updateConfig,
isValidConfigKey,
VALID_CONFIG_KEYS,
} from "../lib/config.js";
import type { Translator, Locale } from "../i18n/index.js";
export interface ConfigOptions {
lang?: Locale;
json?: boolean;
}
export function configCommand(program: Command, t: Translator): void {
const configCmd = program
.command("config")
.description(t("config.description"));
// config show
configCmd
.command("show")
.description(t("config.showDescription"))
.action((options: ConfigOptions, cmd: Command) => {
const globalOpts = cmd.optsWithGlobals();
const json = globalOpts.json;
const config = readConfig();
if (!config) {
if (json) {
console.log(JSON.stringify({ success: false, error: "not_registered" }));
} else {
console.log(t("config.notRegistered"));
}
return;
}
if (json) {
console.log(JSON.stringify({ success: true, config }, null, 2));
} else {
console.log(t("config.currentConfig"));
console.log(` clawId: ${config.clawId}`);
console.log(` name: ${config.name}`);
if (config.endpoint) console.log(` endpoint: ${config.endpoint}`);
if (config.lang) console.log(` lang: ${config.lang}`);
}
});
// config set
configCmd
.command("set <key> <value>")
.description(t("config.setDescription"))
.action((key: string, value: string, options: ConfigOptions, cmd: Command) => {
const globalOpts = cmd.optsWithGlobals();
const json = globalOpts.json;
if (!isValidConfigKey(key)) {
if (json) {
console.log(JSON.stringify({ success: false, error: "invalid_key" }));
} else {
console.log(t("config.invalidKey", { key, validKeys: VALID_CONFIG_KEYS.join(", ") }));
}
process.exit(1);
}
const config = readConfig();
if (!config) {
if (json) {
console.log(JSON.stringify({ success: false, error: "not_registered" }));
} else {
console.log(t("config.notRegistered"));
}
process.exit(1);
}
updateConfig({ [key]: value });
if (json) {
console.log(JSON.stringify({ success: true, key, value }));
} else {
console.log(t("config.setValue", { key, value }));
}
});
// config path
configCmd
.command("path")
.description(t("config.pathDescription"))
.action((options: ConfigOptions, cmd: Command) => {
const globalOpts = cmd.optsWithGlobals();
const json = globalOpts.json;
const configPath = getConfigPath();
if (json) {
console.log(JSON.stringify({ success: true, path: configPath }));
} else {
console.log(t("config.configPath", { path: configPath }));
}
});
// config clear
configCmd
.command("clear")
.description(t("config.clearDescription"))
.action((options: ConfigOptions, cmd: Command) => {
const globalOpts = cmd.optsWithGlobals();
const json = globalOpts.json;
const success = clearConfig();
if (json) {
console.log(JSON.stringify({ success }));
} else {
console.log(t("config.configCleared"));
}
});
}

View File

@@ -0,0 +1,74 @@
import type { Command } from "commander";
import { readConfig, updateConfig } from "../lib/config.js";
import { sendHeartbeat, getEndpoint } from "../lib/api.js";
import { detectPlatform, detectModel } from "../lib/platform.js";
import type { Translator, Locale } from "../i18n/index.js";
export interface HeartbeatOptions {
name?: string;
model?: string;
platform?: string;
endpoint?: string;
lang?: Locale;
json?: boolean;
}
export function heartbeatCommand(program: Command, t: Translator): void {
program
.command("heartbeat")
.description(t("heartbeat.description"))
.option("-n, --name <string>", t("heartbeat.optionName"))
.option("-m, --model <string>", t("heartbeat.optionModel"))
.option("-p, --platform <string>", t("heartbeat.optionPlatform"))
.action(async (options: HeartbeatOptions, cmd: Command) => {
// Get global options
const globalOpts = cmd.optsWithGlobals();
const json = globalOpts.json;
// Check if registered
const config = readConfig();
if (!config) {
if (json) {
console.log(JSON.stringify({ success: false, error: "not_registered" }));
} else {
console.error(t("heartbeat.notRegistered"));
}
process.exit(1);
}
// Get endpoint
const endpoint = getEndpoint(config, options.endpoint);
// Prepare heartbeat data
const heartbeatData = {
endpoint,
apiKey: config.apiKey,
name: options.name,
model: options.model || detectModel(),
platform: options.platform || detectPlatform(),
};
// Call API
const result = await sendHeartbeat(heartbeatData);
if (!result.success) {
if (json) {
console.log(JSON.stringify({ success: false, error: result.error }));
} else {
console.error(t("heartbeat.error", { error: result.error ?? "Unknown error" }));
}
process.exit(1);
}
// Update config if name changed
if (options.name && options.name !== config.name) {
updateConfig({ name: options.name });
}
if (json) {
console.log(JSON.stringify({ success: true }));
} else {
console.log(t("heartbeat.success"));
}
});
}

View File

@@ -0,0 +1,87 @@
import type { Command } from "commander";
import { configExists, readConfig, writeConfig } from "../lib/config.js";
import { registerClaw, getEndpoint } from "../lib/api.js";
import { detectPlatform, detectModel } from "../lib/platform.js";
import { validateRegister } from "../lib/validate.js";
import type { Translator, Locale } from "../i18n/index.js";
export interface RegisterOptions {
platform?: string;
model?: string;
force?: boolean;
endpoint?: string;
lang?: Locale;
json?: boolean;
}
export function registerCommand(program: Command, t: Translator): void {
program
.command("register <name>")
.description(t("register.description"))
.option("-p, --platform <string>", t("register.optionPlatform"))
.option("-m, --model <string>", t("register.optionModel"))
.option("-f, --force", t("register.optionForce"))
.action(async (name: string, options: RegisterOptions, cmd: Command) => {
// Get global options
const globalOpts = cmd.optsWithGlobals();
const json = globalOpts.json;
// Validate input
const validation = validateRegister({ name, ...options });
if (!validation.success) {
console.error(t("register.error", { error: validation.error }));
process.exit(1);
}
// Check if already registered
const existingConfig = readConfig();
if (existingConfig && !options.force) {
if (json) {
console.log(JSON.stringify({ success: false, error: "already_registered", name: existingConfig.name }));
} else {
console.log(t("register.alreadyRegistered", { name: existingConfig.name }));
}
return;
}
// Get endpoint
const endpoint = getEndpoint(existingConfig, globalOpts.endpoint);
// Prepare registration data
const platform = options.platform || detectPlatform();
const model = options.model || detectModel();
// Call API
const result = await registerClaw({
endpoint,
name: validation.data.name,
platform,
model,
});
if (!result.success) {
if (json) {
console.log(JSON.stringify({ success: false, error: result.error }));
} else {
console.error(t("register.error", { error: result.error ?? "Unknown error" }));
}
process.exit(1);
}
// Save config
const config = {
clawId: result.data!.clawId,
apiKey: result.data!.apiKey,
name: result.data!.name,
endpoint: globalOpts.endpoint,
lang: globalOpts.lang,
};
writeConfig(config);
if (json) {
console.log(JSON.stringify({ success: true, ...result.data }));
} else {
console.log(t("register.success", { name: result.data!.name }));
}
});
}

View File

@@ -0,0 +1,95 @@
import type { Command } from "commander";
import { readConfig } from "../lib/config.js";
import { reportTask, getEndpoint } from "../lib/api.js";
import { validateTask } from "../lib/validate.js";
import { detectModel } from "../lib/platform.js";
import type { Translator, Locale } from "../i18n/index.js";
export interface TaskOptions {
duration?: number;
model?: string;
tools?: string[];
endpoint?: string;
lang?: Locale;
json?: boolean;
}
export function taskCommand(program: Command, t: Translator): void {
program
.command("task <summary>")
.description(t("task.description"))
.requiredOption("-d, --duration <ms>", t("task.optionDuration"), (value) => parseInt(value, 10))
.option("-m, --model <string>", t("task.optionModel"))
.option("-t, --tools <tools...>", t("task.optionTools"))
.action(async (summary: string, options: TaskOptions, cmd: Command) => {
// Get global options
const globalOpts = cmd.optsWithGlobals();
const json = globalOpts.json;
// Check if registered
const config = readConfig();
if (!config) {
if (json) {
console.log(JSON.stringify({ success: false, error: "not_registered" }));
} else {
console.error(t("task.notRegistered"));
}
process.exit(1);
}
// Validate duration
if (!options.duration || options.duration <= 0) {
if (json) {
console.log(JSON.stringify({ success: false, error: "invalid_duration" }));
} else {
console.error(t("task.durationRequired"));
}
process.exit(1);
}
// Validate input
const validation = validateTask({
summary,
durationMs: options.duration,
model: options.model,
toolsUsed: options.tools,
});
if (!validation.success) {
if (json) {
console.log(JSON.stringify({ success: false, error: validation.error }));
} else {
console.error(t("task.error", { error: validation.error }));
}
process.exit(1);
}
// Get endpoint
const endpoint = getEndpoint(config, globalOpts.endpoint);
// Call API
const result = await reportTask({
endpoint,
apiKey: config.apiKey,
summary: validation.data.summary,
durationMs: validation.data.durationMs,
model: validation.data.model || detectModel(),
toolsUsed: validation.data.toolsUsed,
});
if (!result.success) {
if (json) {
console.log(JSON.stringify({ success: false, error: result.error }));
} else {
console.error(t("task.error", { error: result.error ?? "Unknown error" }));
}
process.exit(1);
}
if (json) {
console.log(JSON.stringify({ success: true }));
} else {
console.log(t("task.success"));
}
});
}

View File

@@ -0,0 +1,61 @@
{
"cli": {
"description": "CLI tool for OpenClaw Market - report AI agent activity to the global heatmap"
},
"register": {
"description": "Register a new claw on the heatmap",
"argName": "Claw display name (1-100 characters)",
"optionPlatform": "Platform identifier (default: auto-detect)",
"optionModel": "Model identifier (default: from env or 'unknown')",
"optionForce": "Force re-registration even if already registered",
"success": "Registered successfully as: {name}",
"alreadyRegistered": "Already registered as: {name}. Use --force to re-register.",
"error": "Registration failed: {error}",
"nameRequired": "Error: Claw name is required"
},
"heartbeat": {
"description": "Send a heartbeat to update online status",
"optionName": "Update claw name",
"optionModel": "Update model identifier",
"optionPlatform": "Update platform identifier",
"success": "Heartbeat sent successfully",
"error": "Heartbeat failed: {error}",
"notRegistered": "Error: Not registered. Run 'claw-market register' first."
},
"task": {
"description": "Report a completed task",
"argSummary": "Task summary (max 500 characters)",
"optionDuration": "Task duration in milliseconds (required)",
"optionModel": "Model used for the task",
"optionTools": "Tools used (space-separated)",
"success": "Task reported successfully",
"error": "Task report failed: {error}",
"notRegistered": "Error: Not registered. Run 'claw-market register' first.",
"summaryRequired": "Error: Task summary is required",
"durationRequired": "Error: --duration is required"
},
"config": {
"description": "Manage CLI configuration",
"showDescription": "Show current configuration",
"setDescription": "Set a configuration value",
"pathDescription": "Show configuration file path",
"clearDescription": "Clear configuration (unregister)",
"argKey": "Configuration key",
"argValue": "Configuration value",
"currentConfig": "Current configuration:",
"configPath": "Configuration file: {path}",
"configCleared": "Configuration cleared successfully",
"notRegistered": "No configuration found. Run 'claw-market register' first.",
"invalidKey": "Invalid configuration key: {key}. Valid keys: {validKeys}",
"setValue": "Set {key} = {value}"
},
"global": {
"optionEndpoint": "API endpoint (default: https://kymr.top/api/v1)",
"optionLang": "Output language (en/zh)",
"optionJson": "Output in JSON format",
"optionVersion": "Show version",
"optionHelp": "Show help",
"unknownError": "An unexpected error occurred: {error}",
"networkError": "Network error: Unable to connect to {endpoint}"
}
}

View File

@@ -0,0 +1,70 @@
import en from "./en.json" with { type: "json" };
import zh from "./zh.json" with { type: "json" };
export type Locale = "en" | "zh";
export type TranslationKeys = typeof en;
const translations: Record<Locale, TranslationKeys> = { en, zh };
/**
* Detect the user's preferred language
* Priority: --lang option > OPENCLAW_LANG env > config file > system LANG > default 'en'
*/
export function detectLocale(override?: Locale): Locale {
if (override) return override;
const envLang = process.env.OPENCLAW_LANG;
if (envLang === "en" || envLang === "zh") return envLang;
const systemLang = process.env.LANG || process.env.LC_ALL || "";
if (systemLang.toLowerCase().includes("zh")) return "zh";
return "en";
}
/**
* Get translation value by dot-notation key
*/
function getNestedValue(obj: Record<string, unknown>, path: string): string | undefined {
const parts = path.split(".");
let current: unknown = obj;
for (const part of parts) {
if (current && typeof current === "object" && part in current) {
current = (current as Record<string, unknown>)[part];
} else {
return undefined;
}
}
return typeof current === "string" ? current : undefined;
}
/**
* Replace template variables in translation string
*/
function interpolate(template: string, vars: Record<string, string | number>): string {
return template.replace(/\{(\w+)\}/g, (_, key) => String(vars[key] ?? `{${key}}`));
}
/**
* Translation function
*/
export function t(key: string, locale: Locale, vars?: Record<string, string | number>): string {
const translation = getNestedValue(translations[locale] as unknown as Record<string, unknown>, key);
const text = translation ?? key;
if (vars) {
return interpolate(text, vars);
}
return text;
}
/**
* Create a translation function bound to a specific locale
*/
export function createTranslator(locale: Locale) {
return (key: string, vars?: Record<string, string | number>) => t(key, locale, vars);
}
export type Translator = ReturnType<typeof createTranslator>;

View File

@@ -0,0 +1,61 @@
{
"cli": {
"description": "OpenClaw Market 命令行工具 - 向全球热力图报告 AI agent 活动"
},
"register": {
"description": "在热力图上注册一个新的 claw",
"argName": "Claw 显示名称 (1-100 字符)",
"optionPlatform": "平台标识 (默认: 自动检测)",
"optionModel": "模型标识 (默认: 从环境变量或 'unknown')",
"optionForce": "强制重新注册 (即使已注册)",
"success": "注册成功: {name}",
"alreadyRegistered": "已注册为: {name}。使用 --force 强制重新注册。",
"error": "注册失败: {error}",
"nameRequired": "错误: 需要提供 claw 名称"
},
"heartbeat": {
"description": "发送心跳以更新在线状态",
"optionName": "更新 claw 名称",
"optionModel": "更新模型标识",
"optionPlatform": "更新平台标识",
"success": "心跳发送成功",
"error": "心跳发送失败: {error}",
"notRegistered": "错误: 未注册。请先运行 'claw-market register'。"
},
"task": {
"description": "报告已完成的任务",
"argSummary": "任务摘要 (最多 500 字符)",
"optionDuration": "任务时长 (毫秒,必填)",
"optionModel": "任务使用的模型",
"optionTools": "使用的工具 (空格分隔)",
"success": "任务报告成功",
"error": "任务报告失败: {error}",
"notRegistered": "错误: 未注册。请先运行 'claw-market register'。",
"summaryRequired": "错误: 需要提供任务摘要",
"durationRequired": "错误: --duration 是必填项"
},
"config": {
"description": "管理 CLI 配置",
"showDescription": "显示当前配置",
"setDescription": "设置配置值",
"pathDescription": "显示配置文件路径",
"clearDescription": "清除配置 (注销)",
"argKey": "配置键",
"argValue": "配置值",
"currentConfig": "当前配置:",
"configPath": "配置文件: {path}",
"configCleared": "配置已清除",
"notRegistered": "未找到配置。请先运行 'claw-market register'。",
"invalidKey": "无效的配置键: {key}。有效的键: {validKeys}",
"setValue": "已设置 {key} = {value}"
},
"global": {
"optionEndpoint": "API 端点 (默认: https://kymr.top/api/v1)",
"optionLang": "输出语言 (en/zh)",
"optionJson": "以 JSON 格式输出",
"optionVersion": "显示版本",
"optionHelp": "显示帮助",
"unknownError": "发生意外错误: {error}",
"networkError": "网络错误: 无法连接到 {endpoint}"
}
}

View File

@@ -0,0 +1,69 @@
import { Command } from "commander";
import { detectLocale, createTranslator, type Locale } from "./i18n/index.js";
import { registerCommand } from "./commands/register.js";
import { heartbeatCommand } from "./commands/heartbeat.js";
import { taskCommand } from "./commands/task.js";
import { configCommand } from "./commands/config.js";
import { readConfig } from "./lib/config.js";
import { DEFAULT_ENDPOINT } from "./lib/api.js";
const VERSION = "0.1.1";
interface GlobalOptions {
endpoint?: string;
lang?: Locale;
json?: boolean;
}
function main(): void {
// Pre-parse to get language option
const preParseArgs = process.argv.slice(2);
let langOverride: Locale | undefined;
let jsonOutput = false;
for (let i = 0; i < preParseArgs.length; i++) {
const arg = preParseArgs[i];
if (arg === "--lang" || arg === "-l") {
const value = preParseArgs[i + 1];
if (value === "en" || value === "zh") {
langOverride = value;
}
i++;
} else if (arg === "--json") {
jsonOutput = true;
} else if (arg.startsWith("--lang=")) {
const value = arg.split("=")[1];
if (value === "en" || value === "zh") {
langOverride = value;
}
}
}
// Detect locale
const config = readConfig();
const locale = detectLocale(langOverride || config?.lang);
const t = createTranslator(locale);
// Create program
const program = new Command();
program
.name("claw-market")
.description(t("cli.description"))
.version(VERSION)
.option("-e, --endpoint <url>", t("global.optionEndpoint"), DEFAULT_ENDPOINT)
.option("-l, --lang <locale>", t("global.optionLang"))
.option("--json", t("global.optionJson"));
// Add commands
registerCommand(program, t);
heartbeatCommand(program, t);
taskCommand(program, t);
configCommand(program, t);
// Parse
program.parse(process.argv);
}
// Run main
main();

View File

@@ -0,0 +1,176 @@
import type { OpenClawConfig } from "./config.js";
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
}
export interface RegisterResponse {
clawId: string;
apiKey: string;
name: string;
}
export interface ApiError {
error: string;
statusCode?: number;
}
/**
* Default API endpoint
*/
export const DEFAULT_ENDPOINT = "https://kymr.top/api/v1";
/**
* Build full URL for API endpoint
*/
function buildUrl(endpoint: string, path: string): string {
const base = endpoint.endsWith("/api/v1") ? endpoint : `${endpoint}/api/v1`;
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
}
/**
* Make an API request
*/
async function request<T>(
method: string,
path: string,
options: {
endpoint: string;
apiKey?: string;
body?: Record<string, unknown>;
}
): Promise<ApiResponse<T>> {
const { endpoint, apiKey, body } = options;
const url = buildUrl(endpoint, path);
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": "claw-market-cli/0.1.0",
};
if (apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
}
try {
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = await response.json();
if (!response.ok) {
return {
success: false,
error: (data as ApiError).error || `HTTP ${response.status}`,
};
}
return {
success: true,
data: data as T,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
error: message,
};
}
}
/**
* Register a new claw
*/
export async function registerClaw(options: {
endpoint: string;
name: string;
platform?: string;
model?: string;
}): Promise<ApiResponse<RegisterResponse>> {
const body: Record<string, unknown> = { name: options.name };
if (options.platform) {
body.platform = options.platform;
}
if (options.model) {
body.model = options.model;
}
return request<RegisterResponse>("POST", "/register", {
endpoint: options.endpoint,
body,
});
}
/**
* Send a heartbeat
*/
export async function sendHeartbeat(options: {
endpoint: string;
apiKey: string;
name?: string;
platform?: string;
model?: string;
}): Promise<ApiResponse<void>> {
const body: Record<string, unknown> = {};
if (options.name) {
body.name = options.name;
}
if (options.platform) {
body.platform = options.platform;
}
if (options.model) {
body.model = options.model;
}
return request<void>("POST", "/heartbeat", {
endpoint: options.endpoint,
apiKey: options.apiKey,
body,
});
}
/**
* Report a completed task
*/
export async function reportTask(options: {
endpoint: string;
apiKey: string;
summary: string;
durationMs: number;
model?: string;
toolsUsed?: string[];
}): Promise<ApiResponse<void>> {
const body: Record<string, unknown> = {
summary: options.summary,
durationMs: options.durationMs,
};
if (options.model) {
body.model = options.model;
}
if (options.toolsUsed && options.toolsUsed.length > 0) {
body.toolsUsed = options.toolsUsed;
}
return request<void>("POST", "/task", {
endpoint: options.endpoint,
apiKey: options.apiKey,
body,
});
}
/**
* Get endpoint from config or use default
*/
export function getEndpoint(config: OpenClawConfig | null, override?: string): string {
if (override) return override;
if (config?.endpoint) return config.endpoint;
return DEFAULT_ENDPOINT;
}

View File

@@ -0,0 +1,118 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, rmdirSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import type { Locale } from "../i18n/index.js";
export interface OpenClawConfig {
clawId: string;
apiKey: string;
name: string;
endpoint?: string;
lang?: Locale;
}
const CONFIG_DIR = ".openclaw";
const CONFIG_FILE = "config.json";
/**
* Get the configuration directory path
*/
export function getConfigDir(): string {
return join(homedir(), CONFIG_DIR);
}
/**
* Get the configuration file path
*/
export function getConfigPath(): string {
return join(getConfigDir(), CONFIG_FILE);
}
/**
* Check if configuration exists
*/
export function configExists(): boolean {
return existsSync(getConfigPath());
}
/**
* Read configuration from file
*/
export function readConfig(): OpenClawConfig | null {
const configPath = getConfigPath();
if (!existsSync(configPath)) {
return null;
}
try {
const content = readFileSync(configPath, "utf-8");
return JSON.parse(content) as OpenClawConfig;
} catch {
return null;
}
}
/**
* Write configuration to file
* Creates directory if needed and sets permissions to 600 (owner only)
*/
export function writeConfig(config: OpenClawConfig): void {
const configDir = getConfigDir();
const configPath = getConfigPath();
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true, mode: 0o700 });
}
writeFileSync(configPath, JSON.stringify(config, null, 2), {
encoding: "utf-8",
mode: 0o600,
});
}
/**
* Update specific configuration values
*/
export function updateConfig(updates: Partial<OpenClawConfig>): OpenClawConfig | null {
const existing = readConfig();
if (!existing) {
return null;
}
const updated = { ...existing, ...updates };
writeConfig(updated);
return updated;
}
/**
* Delete configuration (unregister)
*/
export function clearConfig(): boolean {
const configPath = getConfigPath();
const configDir = getConfigDir();
try {
if (existsSync(configPath)) {
unlinkSync(configPath);
}
if (existsSync(configDir)) {
rmdirSync(configDir);
}
return true;
} catch {
return false;
}
}
/**
* Valid configuration keys for set operation
*/
export const VALID_CONFIG_KEYS = ["endpoint", "lang"] as const;
export type ConfigKey = (typeof VALID_CONFIG_KEYS)[number];
/**
* Check if a key is a valid configuration key
*/
export function isValidConfigKey(key: string): key is ConfigKey {
return VALID_CONFIG_KEYS.includes(key as ConfigKey);
}

View File

@@ -0,0 +1,23 @@
/**
* Detect the current platform
*/
export function detectPlatform(): string {
const platform = process.platform;
switch (platform) {
case "darwin":
return "darwin";
case "linux":
return "linux";
case "win32":
return "windows";
default:
return platform;
}
}
/**
* Get model identifier from environment or default
*/
export function detectModel(): string {
return process.env.CLAUDE_MODEL || process.env.ANTHROPIC_MODEL || "unknown";
}

View File

@@ -0,0 +1,66 @@
import { z } from "zod";
/**
* Register input validation schema
*/
export const registerSchema = z.object({
name: z.string().min(1, "Name must be at least 1 character").max(100, "Name must be at most 100 characters"),
platform: z.string().optional(),
model: z.string().optional(),
});
/**
* Heartbeat input validation schema
*/
export const heartbeatSchema = z.object({
name: z.string().optional(),
platform: z.string().optional(),
model: z.string().optional(),
});
/**
* Task input validation schema
*/
export const taskSchema = z.object({
summary: z.string().max(500, "Summary must be at most 500 characters"),
durationMs: z.number().positive("Duration must be a positive number"),
model: z.string().optional(),
toolsUsed: z.array(z.string()).optional(),
});
export type RegisterInput = z.infer<typeof registerSchema>;
export type HeartbeatInput = z.infer<typeof heartbeatSchema>;
export type TaskInput = z.infer<typeof taskSchema>;
/**
* Validate register input
*/
export function validateRegister(input: unknown): { success: true; data: RegisterInput } | { success: false; error: string } {
const result = registerSchema.safeParse(input);
if (result.success) {
return { success: true, data: result.data };
}
return { success: false, error: result.error.errors[0]?.message || "Validation failed" };
}
/**
* Validate heartbeat input
*/
export function validateHeartbeat(input: unknown): { success: true; data: HeartbeatInput } | { success: false; error: string } {
const result = heartbeatSchema.safeParse(input);
if (result.success) {
return { success: true, data: result.data };
}
return { success: false, error: result.error.errors[0]?.message || "Validation failed" };
}
/**
* Validate task input
*/
export function validateTask(input: unknown): { success: true; data: TaskInput } | { success: false; error: string } {
const result = taskSchema.safeParse(input);
if (result.success) {
return { success: true, data: result.data };
}
return { success: false, error: result.error.errors[0]?.message || "Validation failed" };
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,15 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
target: "node18",
clean: true,
dts: true,
sourcemap: true,
minify: false,
banner: {
js: "#!/usr/bin/env node",
},
external: ["commander", "zod"],
});

1402
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
packages:
- "packages/*"

View File

@@ -14,8 +14,7 @@ requirements:
writeFiles: writeFiles:
- "~/.openclaw/config.json" - "~/.openclaw/config.json"
binaries: binaries:
- curl - claw-market
- uname
env: env:
- name: CLAUDE_MODEL - name: CLAUDE_MODEL
required: false required: false
@@ -24,7 +23,7 @@ hooks:
SessionStart: SessionStart:
- hooks: - hooks:
- type: command - type: command
command: "CONFIG=\"$HOME/.openclaw/config.json\"; [ -f \"$CONFIG\" ] && KEY=$(grep -o '\"apiKey\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' \"$CONFIG\" | head -1 | sed 's/.*\"\\([^\"]*\\)\"$/\\1/') && [ -n \"$KEY\" ] && curl -s -o /dev/null --max-time 5 -X POST 'https://kymr.top/api/v1/heartbeat' -H 'Content-Type: application/json' -H \"Authorization: Bearer $KEY\" -d \"{\\\"platform\\\":\\\"$(uname -s | tr '[:upper:]' '[:lower:]')\\\",\\\"model\\\":\\\"${CLAUDE_MODEL:-unknown}\\\"}\" 2>/dev/null || true" command: "claw-market heartbeat 2>/dev/null || true"
timeout: 10 timeout: 10
--- ---
@@ -32,6 +31,14 @@ hooks:
This skill lets you participate in the OpenClaw global claw activity heatmap. It reports **minimal, non-code** activity data to visualize worldwide AI agent usage. Note: as with any HTTPS request, the server can observe your IP address. This skill lets you participate in the OpenClaw global claw activity heatmap. It reports **minimal, non-code** activity data to visualize worldwide AI agent usage. Note: as with any HTTPS request, the server can observe your IP address.
## Prerequisites
This skill requires the `claw-market` CLI tool. Install it globally:
```bash
npm install -g @ricardweii/claw-market
```
## How It Works ## How It Works
- **Heartbeat**: Sent once when the skill is loaded (`SessionStart` hook). No other automated hooks are used — no `PostToolUse`, no `Stop` hooks. - **Heartbeat**: Sent once when the skill is loaded (`SessionStart` hook). No other automated hooks are used — no `PostToolUse`, no `Stop` hooks.
@@ -57,13 +64,23 @@ This skill sends the following data to `https://kymr.top/` (the OpenClaw Market
## Configuration ## Configuration
Config is stored at `~/.openclaw/config.json` (permissions `600`). No external scripts are deployed — the heartbeat is sent inline via the `SessionStart` hook command. Config is stored at `~/.openclaw/config.json` (permissions `600`). Managed by the `claw-market` CLI tool.
## First-Time Setup (Requires User Consent) ## First-Time Setup (Requires User Consent)
If `~/.openclaw/config.json` does not exist, follow these steps: If `~/.openclaw/config.json` does not exist, follow these steps:
### Step 1: Ask for consent ### Step 1: Check CLI availability
First, verify `claw-market` is installed:
```bash
which claw-market || echo "NOT_INSTALLED"
```
If not installed, inform the user: "The openclaw-reporter skill requires the claw-market CLI. Install with: `npm install -g @ricardweii/claw-market`"
### Step 2: Ask for consent
Use the AskUserQuestion tool: Use the AskUserQuestion tool:
@@ -72,7 +89,7 @@ Use the AskUserQuestion tool:
**If the user selects "No, skip":** do NOT register and do NOT ask again in this session. Stop here. **If the user selects "No, skip":** do NOT register and do NOT ask again in this session. Stop here.
### Step 2: Ask for a claw name ### Step 3: Ask for a claw name
If the user consented, use AskUserQuestion again: If the user consented, use AskUserQuestion again:
@@ -82,56 +99,21 @@ If the user consented, use AskUserQuestion again:
Use whatever the user provides as the claw name. Use whatever the user provides as the claw name.
### Step 3: Register and save config ### Step 4: Register using CLI
After receiving the name (stored as `CLAW_NAME`), run the following: After receiving the name, validate and register:
```bash ```bash
# --- OpenClaw Registration --- # Validate claw name: only allow alphanumeric, hyphens, underscores (1-30 chars).
set -e
OPENCLAW_ENDPOINT="https://kymr.top"
CLAW_NAME="USER_CHOSEN_NAME_HERE" CLAW_NAME="USER_CHOSEN_NAME_HERE"
# Validate claw name: only allow alphanumeric, hyphens, underscores (1-30 chars).
# This whitelist ensures $CLAW_NAME is safe for shell interpolation and JSON embedding.
if ! echo "$CLAW_NAME" | grep -qE '^[A-Za-z0-9_-]{1,30}$'; then if ! echo "$CLAW_NAME" | grep -qE '^[A-Za-z0-9_-]{1,30}$'; then
echo "Error: Claw name must be 1-30 characters, alphanumeric/hyphens/underscores only." echo "Error: Claw name must be 1-30 characters, alphanumeric/hyphens/underscores only."
exit 1 exit 1
fi fi
mkdir -p ~/.openclaw # Register using CLI
claw-market register "$CLAW_NAME"
# Register with the server
# Safe: CLAW_NAME is validated above to contain only [A-Za-z0-9_-]
RESPONSE=$(curl -s -X POST "$OPENCLAW_ENDPOINT/api/v1/register" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"$CLAW_NAME\",
\"platform\": \"$(uname -s | tr '[:upper:]' '[:lower:]')\",
\"model\": \"${CLAUDE_MODEL:-unknown}\"
}")
# Extract fields from JSON response using grep/sed (no python3 needed)
CLAW_ID=$(echo "$RESPONSE" | grep -o '"clawId"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
API_KEY=$(echo "$RESPONSE" | grep -o '"apiKey"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
if [ -z "$API_KEY" ]; then
echo "Error: Registration failed. Server response: $RESPONSE"
exit 1
fi
# Write config file
cat > ~/.openclaw/config.json << CFGEOF
{
"clawId": "$CLAW_ID",
"apiKey": "$API_KEY",
"name": "$CLAW_NAME"
}
CFGEOF
chmod 600 ~/.openclaw/config.json
echo "Registered as: $CLAW_NAME"
``` ```
## Task Reporting (Manual Only) ## Task Reporting (Manual Only)
@@ -141,15 +123,10 @@ Task reports are **never sent automatically**. Only send a task report when the
When triggered, run: When triggered, run:
```bash ```bash
CONFIG="$HOME/.openclaw/config.json" # Estimate task duration (ask user or use a default)
[ -f "$CONFIG" ] || { echo "OpenClaw not registered."; exit 0; } DURATION_MS=60000 # Default 1 minute, adjust based on actual task time
KEY=$(grep -o '"apiKey"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
[ -z "$KEY" ] && { echo "No API key found."; exit 0; } claw-market task "Completed a task" --duration "$DURATION_MS" || echo "Task report failed (non-blocking)."
curl -s -o /dev/null --max-time 5 -X POST 'https://kymr.top/api/v1/task' \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $KEY" \
-d "{\"summary\":\"Completed a task\",\"model\":\"${CLAUDE_MODEL:-unknown}\"}" 2>/dev/null \
&& echo "Task reported." || echo "Task report failed (non-blocking)."
``` ```
## Opt-Out / Unregister ## Opt-Out / Unregister
@@ -157,9 +134,7 @@ curl -s -o /dev/null --max-time 5 -X POST 'https://kymr.top/api/v1/task' \
To stop all OpenClaw reporting: To stop all OpenClaw reporting:
```bash ```bash
rm -f ~/.openclaw/config.json claw-market config clear
rmdir ~/.openclaw 2>/dev/null
echo "OpenClaw fully uninstalled."
``` ```
After deletion, no heartbeats or task reports will be sent. The skill will ask for consent again if re-enabled. After deletion, no heartbeats or task reports will be sent. The skill will ask for consent again if re-enabled.
@@ -172,5 +147,19 @@ After deletion, no heartbeats or task reports will be sent. The skill will ask f
4. **Task reports are manual only** — only send when the user explicitly mentions completing a task or asks to report 4. **Task reports are manual only** — only send when the user explicitly mentions completing a task or asks to report
5. If any OpenClaw request fails, continue normally — never interrupt the user's workflow 5. If any OpenClaw request fails, continue normally — never interrupt the user's workflow
6. Never include identifying information in task summaries 6. Never include identifying information in task summaries
7. **No external scripts**: all logic is inline or executed by the agent directly 7. **No system-level modifications**: never write to `~/.claude/settings.json` or any file outside `~/.openclaw/`
8. **No system-level modifications**: never write to `~/.claude/settings.json` or any file outside `~/.openclaw/`
## CLI Reference
The `claw-market` CLI provides these commands:
| Command | Description |
|---------|-------------|
| `claw-market register <name>` | Register a new claw |
| `claw-market heartbeat` | Send a heartbeat |
| `claw-market task <summary> -d <ms>` | Report a completed task |
| `claw-market config show` | Show current configuration |
| `claw-market config path` | Show config file path |
| `claw-market config clear` | Delete configuration (unregister) |
Global options: `--lang <en\|zh>`, `--json`, `--endpoint <url>`