feat: 添加设备注册 API 并重构安装横幅组件

This commit is contained in:
richarjiang
2026-03-24 09:18:51 +08:00
parent ab00443056
commit 71628d783d
13 changed files with 1524 additions and 150 deletions

View File

@@ -50,23 +50,18 @@ export default function HomePage() {
<Hero /> <Hero />
{/* Section 1: Main Map View + Dashboard */} {/* Section 1: Main Map View + Dashboard */}
<section className="min-h-[calc(100vh-5rem)]"> <section id="get-started">
<div className="mb-4"> <div className="grid gap-4 lg:grid-cols-[240px_1fr_280px]">
<InstallBanner /> {/* Left Panel — Stats */}
</div> <div className="flex flex-col gap-3">
<div className="grid gap-4 lg:grid-cols-[280px_1fr_280px]">
{/* Left Panel */}
<div className="flex flex-col gap-4">
<StatsPanel /> <StatsPanel />
<RegionRanking /> <RegionRanking />
<TokenLeaderboard /> <TokenLeaderboard />
</div> </div>
{/* Center - Map/Globe + Timeline */} {/* Center Map/Globe (hero of the page) */}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* View Switcher + Map Container */} <div className="relative h-[520px] lg:h-[680px]">
<div className="relative h-[500px] lg:h-[600px]">
{/* View Mode Toggle */} {/* View Mode Toggle */}
<div className="absolute left-4 top-4 z-20 flex rounded-lg border border-white/10 bg-[var(--bg-card)]/90 p-1 backdrop-blur-sm"> <div className="absolute left-4 top-4 z-20 flex rounded-lg border border-white/10 bg-[var(--bg-card)]/90 p-1 backdrop-blur-sm">
<button <button
@@ -95,12 +90,9 @@ export default function HomePage() {
</button> </button>
</div> </div>
{/* Map View */}
{viewMode === "2d" && ( {viewMode === "2d" && (
<WorldMap className="h-full w-full" /> <WorldMap className="h-full w-full" />
)} )}
{/* Globe View */}
{viewMode === "3d" && ( {viewMode === "3d" && (
<GlobeView /> <GlobeView />
)} )}
@@ -109,8 +101,9 @@ export default function HomePage() {
<ActivityTimeline /> <ActivityTimeline />
</div> </div>
{/* Right Panel */} {/* Right Panel — Register + Feed */}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<InstallBanner />
<ClawFeed /> <ClawFeed />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { claws } from "@/lib/db/schema";
import { authenticateRequest } from "@/lib/auth/request";
import { updateNameSchema } from "@/lib/validators/schemas";
export async function PUT(req: NextRequest) {
try {
const auth = await authenticateRequest(req);
if (auth instanceof NextResponse) {
return auth;
}
const { claw } = auth;
const body = await req.json();
const parsed = updateNameSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 }
);
}
const { name } = parsed.data;
await db
.update(claws)
.set({ name, updatedAt: new Date() })
.where(eq(claws.id, claw.id));
return NextResponse.json({ ok: true, name });
} catch (error) {
console.error("Update name error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,150 @@
import { NextRequest, NextResponse } from "next/server";
import { nanoid } from "nanoid";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { claws } from "@/lib/db/schema";
import { redis } from "@/lib/redis";
import { generateApiKey } from "@/lib/auth/api-key";
import { getGeoLocation } from "@/lib/geo/ip-location";
import { applyDeterministicOffset } from "@/lib/geo/offset";
import { deviceRegisterSchema } from "@/lib/validators/schemas";
function getClientIp(req: NextRequest): string {
const forwarded = req.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0].trim();
const realIp = req.headers.get("x-real-ip");
if (realIp) return realIp.trim();
return "127.0.0.1";
}
const RATE_LIMIT_KEY_PREFIX = "ratelimit:device:";
const RATE_LIMIT_MAX = 10;
const RATE_LIMIT_WINDOW = 3600; // 1 hour
export async function POST(req: NextRequest) {
try {
const clientIp = getClientIp(req);
// Rate limit check
const rateLimitKey = `${RATE_LIMIT_KEY_PREFIX}${clientIp}`;
const currentCount = await redis.incr(rateLimitKey);
if (currentCount === 1) {
await redis.expire(rateLimitKey, RATE_LIMIT_WINDOW);
}
if (currentCount > RATE_LIMIT_MAX) {
return NextResponse.json(
{ error: "Too many requests. Try again later." },
{ status: 429 }
);
}
const body = await req.json();
const parsed = deviceRegisterSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 }
);
}
const { deviceId, name, platform } = parsed.data;
// Check if device already registered
const existing = await db
.select()
.from(claws)
.where(eq(claws.deviceId, deviceId))
.limit(1);
if (existing.length > 0) {
return NextResponse.json({
clawId: existing[0].id,
apiKey: existing[0].apiKey,
name: existing[0].name,
isNew: false,
});
}
// Create new claw
const clawId = nanoid(21);
const apiKey = generateApiKey();
const geo = await getGeoLocation(clientIp);
const now = new Date();
let finalLat: number | null = null;
let finalLng: number | null = null;
if (geo) {
const offset = applyDeterministicOffset(
geo.latitude,
geo.longitude,
clawId
);
finalLat = offset.lat;
finalLng = offset.lng;
}
// Auto-generate name if not provided
const clawName = name || generateClawName();
await db.insert(claws).values({
id: clawId,
apiKey,
deviceId,
name: clawName,
platform: platform ? platform.slice(0, 20) : null,
ip: clientIp,
latitude: finalLat !== null ? String(finalLat) : null,
longitude: finalLng !== null ? String(finalLng) : null,
city: geo?.city ?? null,
country: geo?.country ?? null,
countryCode: geo?.countryCode ?? null,
region: geo?.region ?? null,
lastHeartbeat: now,
totalTasks: 0,
createdAt: now,
updatedAt: now,
});
return NextResponse.json({
clawId,
apiKey,
name: clawName,
isNew: true,
});
} catch (error) {
console.error("Device register error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
const ADJECTIVES = [
"Swift",
"Brave",
"Cool",
"Dark",
"Fire",
"Ice",
"Storm",
"Shadow",
"Cyber",
"Neon",
"Pixel",
"Turbo",
"Mega",
"Ultra",
"Nova",
"Star",
"Flash",
"Thunder",
"Iron",
"Cosmic",
];
function generateClawName(): string {
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const num = Math.floor(Math.random() * 100);
return `${adj}Claw-${num}`;
}

View File

@@ -181,6 +181,7 @@ export function ClawFeed() {
<Badge variant="secondary">{formatDuration(item.durationMs)}</Badge> <Badge variant="secondary">{formatDuration(item.durationMs)}</Badge>
)} )}
<span className="text-[10px] text-[var(--text-muted)]"> <span className="text-[10px] text-[var(--text-muted)]">
{new Date(item.timestamp).toLocaleDateString(locale, { month: "2-digit", day: "2-digit" })}{" "}
{new Date(item.timestamp).toLocaleTimeString(locale)} {new Date(item.timestamp).toLocaleTimeString(locale)}
</span> </span>
</div> </div>

View File

@@ -8,36 +8,36 @@ export function Hero() {
const t = useTranslations("hero"); const t = useTranslations("hero");
return ( return (
<section className="relative py-16 md:py-24"> <section className="relative py-6 md:py-10">
<div className="mx-auto max-w-4xl px-4 text-center"> <div className="mx-auto max-w-4xl px-4 text-center">
{/* Badge */} {/* Badge */}
<Badge className="mb-6 px-4 py-1.5 text-sm animate-fade-in"> <Badge className="mb-3 px-3 py-1 text-xs animate-fade-in">
{t("badge")} {t("badge")}
</Badge> </Badge>
{/* Main Title */} {/* Main Title */}
<h1 className="mb-6 text-4xl font-bold leading-tight tracking-tight md:text-5xl lg:text-6xl glow-text-pulse animate-fade-in-up"> <h1 className="mb-3 text-2xl font-bold leading-tight tracking-tight md:text-3xl lg:text-4xl glow-text-pulse animate-fade-in-up">
{t("title")} {t("title")}
</h1> </h1>
{/* Subtitle */} {/* Subtitle */}
<p className="mb-8 text-lg md:text-xl text-[var(--text-secondary)] max-w-2xl mx-auto animate-fade-in-up animation-delay-200"> <p className="mb-4 text-sm md:text-base text-[var(--text-secondary)] max-w-2xl mx-auto animate-fade-in-up animation-delay-200">
{t("subtitle")} {t("subtitle")}
</p> </p>
{/* CTA Buttons */} {/* CTA Buttons */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 animate-fade-in-up animation-delay-400"> <div className="flex flex-col sm:flex-row items-center justify-center gap-3 animate-fade-in-up animation-delay-400">
<a <a
href="#get-started" href="#get-started"
className="group inline-flex items-center gap-2 rounded-lg bg-[var(--accent-cyan)] px-6 py-3 font-semibold text-[var(--bg-primary)] transition-all hover:shadow-[0_0_20px_rgba(0,240,255,0.4)] hover:scale-105" className="group inline-flex items-center gap-2 rounded-lg bg-[var(--accent-cyan)] px-5 py-2.5 text-sm font-semibold text-[var(--bg-primary)] transition-all hover:shadow-[0_0_20px_rgba(0,240,255,0.4)] hover:scale-105"
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="h-3.5 w-3.5" />
{t("cta")} {t("cta")}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" /> <ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-1" />
</a> </a>
<a <a
href="#learn-more" href="#learn-more"
className="inline-flex items-center gap-2 rounded-lg border border-[var(--accent-cyan)]/30 bg-transparent px-6 py-3 font-semibold text-[var(--text-primary)] transition-all hover:border-[var(--accent-cyan)]/60 hover:bg-[var(--accent-cyan)]/5" className="inline-flex items-center gap-2 rounded-lg border border-[var(--accent-cyan)]/30 bg-transparent px-5 py-2.5 text-sm font-semibold text-[var(--text-primary)] transition-all hover:border-[var(--accent-cyan)]/60 hover:bg-[var(--accent-cyan)]/5"
> >
{t("secondary")} {t("secondary")}
</a> </a>

View File

@@ -1,124 +1,329 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useCallback } from "react";
import { Check, Copy, MessageSquare, Bot } from "lucide-react"; import {
Check,
Copy,
MessageSquare,
Terminal,
Pencil,
Eye,
EyeOff,
Bot,
Loader2,
} from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useDeviceToken } from "@/hooks/use-device-token";
import { cn } from "@/lib/utils";
type Tab = "openclaw" | "terminal";
export function InstallBanner() { export function InstallBanner() {
const t = useTranslations("installBanner"); const t = useTranslations("installBanner");
const tGuide = useTranslations("skillGuide"); const tGuide = useTranslations("skillGuide");
const { token, name, isLoading, error, updateName } = useDeviceToken();
const [activeTab, setActiveTab] = useState<Tab>("terminal");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [isEditingName, setIsEditingName] = useState(false);
const [editNameValue, setEditNameValue] = useState("");
const [nameStatus, setNameStatus] = useState<
"idle" | "saving" | "success" | "error"
>("idle");
const [showToken, setShowToken] = useState(false);
const prompt = t("prompt"); const maskedToken = token ? `${token.slice(0, 6)}...` : "";
const handleCopy = async () => { const openclawPrompt = token ? t("openclawPrompt", { token }) : "";
const terminalCommand = token ? t("terminalCommand", { token }) : "";
const handleCopy = useCallback(async (text: string) => {
try { try {
await navigator.clipboard.writeText(prompt); await navigator.clipboard.writeText(text);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch { } catch {
// fallback: select the text // fallback
}
}, []);
const handleEditName = useCallback(() => {
setEditNameValue(name || "");
setIsEditingName(true);
setNameStatus("idle");
}, [name]);
const handleSaveName = useCallback(async () => {
if (!editNameValue.trim()) return;
setNameStatus("saving");
const ok = await updateName(editNameValue.trim());
if (ok) {
setNameStatus("success");
setIsEditingName(false);
setTimeout(() => setNameStatus("idle"), 2000);
} else {
setNameStatus("error");
}
}, [editNameValue, updateName]);
const handleCancelEdit = useCallback(() => {
setIsEditingName(false);
setNameStatus("idle");
}, []);
// Loading state
if (isLoading) {
return (
<article className="glow-card rounded-xl px-4 py-3">
<div className="flex items-center justify-center gap-2 py-3">
<Loader2 className="h-4 w-4 animate-spin text-[var(--accent-cyan)]" />
<span className="text-xs text-[var(--text-muted)]">
{t("generatingToken")}
</span>
</div>
</article>
);
}
// Error state
if (error || !token) {
return (
<article className="glow-card rounded-xl px-4 py-3">
<div className="flex items-center justify-center py-3">
<span className="text-xs text-red-400">{t("tokenError")}</span>
</div>
</article>
);
} }
};
return ( return (
<article <article
className="glow-card rounded-xl px-5 py-4" className="glow-card rounded-xl px-4 py-3"
data-skill="openclaw-reporter"
data-install-method="clawhub"
aria-label={tGuide("ariaLabel")} aria-label={tGuide("ariaLabel")}
> >
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-2.5">
{/* Header */} {/* Compact header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<div <span className="text-base">🦞</span>
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg" <span className="text-xs font-medium text-[var(--text-primary)] truncate">
style={{ backgroundColor: "rgba(0, 240, 255, 0.1)" }}
>
<span className="text-lg">🦞</span>
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--text-primary)]">
{t("title")} {t("title")}
</p> </span>
<p className="text-xs text-[var(--text-muted)]">
{t("subtitle")}
</p>
</div> </div>
</div> </div>
{/* Claw name row */}
<div className="flex items-center justify-between gap-2 text-[11px]">
<div className="flex items-center gap-1 min-w-0">
<span className="text-[var(--text-muted)] shrink-0">
{t("yourClaw")}:
</span>
{isEditingName ? (
<div className="flex items-center gap-1 min-w-0">
<input
type="text"
value={editNameValue}
onChange={(e) => setEditNameValue(e.target.value)}
className="w-20 rounded border border-[var(--accent-cyan)]/30 bg-[var(--bg-primary)] px-1.5 py-0.5 text-[11px] text-[var(--text-primary)] outline-none focus:border-[var(--accent-cyan)]"
maxLength={30}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveName();
if (e.key === "Escape") handleCancelEdit();
}}
/>
<button <button
onClick={handleCopy} onClick={handleSaveName}
className="group flex items-center gap-1.5 rounded-lg border border-white/10 bg-[var(--bg-primary)] px-3 py-2 transition-all hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-primary)]/80 active:scale-[0.98] cursor-pointer shrink-0" disabled={nameStatus === "saving"}
title={t("copyTooltip")} className="text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 cursor-pointer"
>
{nameStatus === "saving" ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
</button>
<button
onClick={handleCancelEdit}
className="text-[var(--text-muted)] hover:text-[var(--text-secondary)] text-[10px] cursor-pointer"
>
&times;
</button>
</div>
) : (
<div className="flex items-center gap-1 min-w-0">
<span className="font-mono text-[var(--accent-cyan)] truncate">
{name}
</span>
{nameStatus === "success" && (
<Check className="h-2.5 w-2.5 text-[var(--accent-green)]" />
)}
<button
onClick={handleEditName}
className="text-[var(--text-muted)] hover:text-[var(--accent-cyan)] cursor-pointer shrink-0"
>
<Pencil className="h-2.5 w-2.5" />
</button>
</div>
)}
</div>
{/* Token mini display */}
<button
onClick={() => setShowToken(!showToken)}
className="flex items-center gap-1 text-[var(--text-muted)] hover:text-[var(--text-secondary)] cursor-pointer shrink-0"
title={showToken ? t("hideToken") : t("showToken")}
>
<code className="font-mono text-[10px]">
{showToken ? token.slice(0, 12) + "..." : maskedToken}
</code>
{showToken ? (
<EyeOff className="h-2.5 w-2.5" />
) : (
<Eye className="h-2.5 w-2.5" />
)}
</button>
</div>
{/* Compact tabs */}
<div className="flex rounded-md border border-white/10 bg-[var(--bg-primary)] p-0.5">
<button
onClick={() => setActiveTab("openclaw")}
className={cn(
"flex flex-1 items-center justify-center gap-1 rounded px-2 py-1 text-[10px] font-medium transition-all cursor-pointer",
activeTab === "openclaw"
? "bg-[var(--accent-cyan)]/15 text-[var(--accent-cyan)]"
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
)}
>
<MessageSquare className="h-3 w-3" />
{t("tabOpenClaw")}
</button>
<button
onClick={() => setActiveTab("terminal")}
className={cn(
"flex flex-1 items-center justify-center gap-1 rounded px-2 py-1 text-[10px] font-medium transition-all cursor-pointer",
activeTab === "terminal"
? "bg-[var(--accent-cyan)]/15 text-[var(--accent-cyan)]"
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
)}
>
<Terminal className="h-3 w-3" />
{t("tabTerminal")}
</button>
</div>
{/* Tab content */}
{activeTab === "openclaw" && (
<div className="flex flex-col gap-1.5">
<button
onClick={() => handleCopy(openclawPrompt)}
className="group relative w-full cursor-pointer rounded-md border border-white/10 bg-[var(--bg-primary)] p-2.5 text-left transition-all hover:border-[var(--accent-cyan)]/30"
>
<p className="text-[11px] leading-relaxed text-[var(--text-secondary)] select-all break-all line-clamp-4">
{openclawPrompt}
</p>
<div className="absolute right-1.5 top-1.5 opacity-0 transition-opacity group-hover:opacity-100">
{copied ? (
<Check className="h-3 w-3 text-[var(--accent-green)]" />
) : (
<Copy className="h-3 w-3 text-[var(--text-muted)]" />
)}
</div>
</button>
<button
onClick={() => handleCopy(openclawPrompt)}
className="group flex w-full items-center justify-center gap-1 rounded-md border border-white/10 bg-[var(--bg-primary)] py-1.5 text-[10px] transition-all hover:border-[var(--accent-cyan)]/40 cursor-pointer"
> >
{copied ? ( {copied ? (
<Check className="h-3.5 w-3.5 text-[var(--accent-green)]" /> <Check className="h-2.5 w-2.5 text-[var(--accent-green)]" />
) : ( ) : (
<Copy className="h-3.5 w-3.5 text-[var(--text-muted)] transition-colors group-hover:text-[var(--accent-cyan)]" /> <Copy className="h-2.5 w-2.5 text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)]" />
)} )}
<span className="text-xs text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)]"> <span className="text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)]">
{copied ? t("copied") : t("copyButton")} {copied ? t("copied") : t("copyPrompt")}
</span> </span>
</button> </button>
</div> </div>
)}
{/* Human prompt card */} {activeTab === "terminal" && (
<button <div className="flex flex-col gap-1.5">
onClick={handleCopy} <div className="rounded-md border border-white/10 bg-[var(--bg-primary)] p-2.5">
className="group relative w-full cursor-pointer rounded-lg border border-white/10 bg-[var(--bg-primary)] p-3 text-left transition-all hover:border-[var(--accent-cyan)]/30" <div className="flex items-start gap-1.5">
> <span className="font-mono text-[11px] text-[var(--accent-green)] select-none">
<div className="flex gap-2"> $
<MessageSquare className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--accent-cyan)]/60" /> </span>
<p className="text-xs leading-relaxed text-[var(--text-secondary)] select-all"> <code className="font-mono text-[11px] leading-relaxed text-[var(--accent-cyan)] select-all break-all">
{prompt} {terminalCommand}
</p> </code>
</div> </div>
</div>
<button
onClick={() => handleCopy(terminalCommand)}
className="group flex w-full items-center justify-center gap-1 rounded-md border border-white/10 bg-[var(--bg-primary)] py-1.5 text-[10px] transition-all hover:border-[var(--accent-cyan)]/40 cursor-pointer"
>
{copied ? (
<Check className="h-2.5 w-2.5 text-[var(--accent-green)]" />
) : (
<Copy className="h-2.5 w-2.5 text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)]" />
)}
<span className="text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)]">
{copied ? t("copied") : t("copyCommand")}
</span>
</button> </button>
</div>
)}
{/* AI-crawlable installation guide (collapsed for humans, fully in DOM for crawlers) */} {/* AI-crawlable guide (collapsed) */}
<details className="group/details"> <details className="group/details">
<summary className="flex cursor-pointer items-center gap-1.5 text-[10px] text-[var(--text-muted)] hover:text-[var(--text-secondary)]"> <summary className="flex cursor-pointer items-center gap-1 text-[10px] text-[var(--text-muted)] hover:text-[var(--text-secondary)]">
<Bot className="h-3 w-3" /> <Bot className="h-2.5 w-2.5" />
<span>{tGuide("summaryLabel")}</span> <span>{tGuide("summaryLabel")}</span>
</summary> </summary>
<div className="mt-2 space-y-2 rounded-md border border-white/5 bg-[var(--bg-primary)] p-3 text-[10px] leading-relaxed text-[var(--text-muted)]">
<div className="mt-3 space-y-3 rounded-lg border border-white/5 bg-[var(--bg-primary)] p-4 text-xs leading-relaxed text-[var(--text-muted)]">
<section> <section>
<h3 className="mb-1 font-mono text-[var(--text-secondary)]">{tGuide("whatTitle")}</h3> <h3 className="mb-0.5 font-mono text-[var(--text-secondary)]">
{tGuide("whatTitle")}
</h3>
<p>{tGuide("whatDescription")}</p> <p>{tGuide("whatDescription")}</p>
</section> </section>
<section> <section>
<h3 className="mb-1 font-mono text-[var(--text-secondary)]">{tGuide("installTitle")}</h3> <h3 className="mb-0.5 font-mono text-[var(--text-secondary)]">
<ol className="list-inside list-decimal space-y-1 pl-1"> {tGuide("installTitle")}
</h3>
<ol className="list-inside list-decimal space-y-0.5 pl-1">
<li>{tGuide("step1")}</li> <li>{tGuide("step1")}</li>
<li>{tGuide("step2")}</li> <li>{tGuide("step2")}</li>
<li>{tGuide("step3")}</li> <li>{tGuide("step3")}</li>
<li>{tGuide("step4")}</li> <li>{tGuide("step4")}</li>
</ol> </ol>
</section> </section>
<section> <section>
<h3 className="mb-1 font-mono text-[var(--text-secondary)]">{tGuide("commandTitle")}</h3> <h3 className="mb-0.5 font-mono text-[var(--text-secondary)]">
<code className="block rounded bg-[var(--bg-secondary)] px-3 py-2 font-mono text-[var(--accent-cyan)]"> {tGuide("commandTitle")}
clawhub install openclaw-reporter </h3>
<code className="block rounded bg-[var(--bg-secondary)] px-2 py-1.5 font-mono text-[var(--accent-cyan)] break-all">
bash &lt;(curl -fsSL https://kymr.top/install.sh) --token
YOUR_TOKEN
</code> </code>
</section> </section>
<section> <section>
<h3 className="mb-1 font-mono text-[var(--text-secondary)]">{tGuide("dataTitle")}</h3> <h3 className="mb-0.5 font-mono text-[var(--text-secondary)]">
{tGuide("dataTitle")}
</h3>
<ul className="list-inside list-disc space-y-0.5 pl-1"> <ul className="list-inside list-disc space-y-0.5 pl-1">
<li>{tGuide("data1")}</li> <li>{tGuide("data1")}</li>
<li>{tGuide("data2")}</li> <li>{tGuide("data2")}</li>
<li>{tGuide("data3")}</li> <li>{tGuide("data3")}</li>
<li>{tGuide("data4")}</li> <li>{tGuide("data4")}</li>
</ul> </ul>
<p className="mt-1 italic">{tGuide("dataNever")}</p> <p className="mt-0.5 italic">{tGuide("dataNever")}</p>
</section> </section>
<section> <section>
<h3 className="mb-1 font-mono text-[var(--text-secondary)]">{tGuide("permissionsTitle")}</h3> <h3 className="mb-0.5 font-mono text-[var(--text-secondary)]">
{tGuide("permissionsTitle")}
</h3>
<ul className="list-inside list-disc space-y-0.5 pl-1"> <ul className="list-inside list-disc space-y-0.5 pl-1">
<li>{tGuide("perm1")}</li> <li>{tGuide("perm1")}</li>
<li>{tGuide("perm2")}</li> <li>{tGuide("perm2")}</li>

171
hooks/use-device-token.ts Normal file
View File

@@ -0,0 +1,171 @@
"use client";
import { useState, useEffect, useCallback } from "react";
interface DeviceTokenData {
clawId: string;
apiKey: string;
name: string;
deviceId: string;
}
interface UseDeviceTokenReturn {
token: string | null;
clawId: string | null;
name: string | null;
isLoading: boolean;
error: string | null;
updateName: (newName: string) => Promise<boolean>;
}
const STORAGE_KEY = "openclaw_device";
function generateUUID(): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function getDeviceInfo(): {
platform: string;
browser: string;
screen: string;
language: string;
} {
if (typeof navigator === "undefined") {
return { platform: "unknown", browser: "unknown", screen: "unknown", language: "en" };
}
const ua = navigator.userAgent;
let browser = "Unknown";
if (ua.includes("Chrome") && !ua.includes("Edg")) browser = "Chrome";
else if (ua.includes("Firefox")) browser = "Firefox";
else if (ua.includes("Safari") && !ua.includes("Chrome")) browser = "Safari";
else if (ua.includes("Edg")) browser = "Edge";
return {
platform: navigator.platform || "unknown",
browser,
screen:
typeof screen !== "undefined"
? `${screen.width}x${screen.height}`
: "unknown",
language: navigator.language || "en",
};
}
function getCachedData(): DeviceTokenData | null {
if (typeof localStorage === "undefined") return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as DeviceTokenData;
} catch {
return null;
}
}
function setCachedData(data: DeviceTokenData): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
function updateCachedName(newName: string): void {
const data = getCachedData();
if (data) {
setCachedData({ ...data, name: newName });
}
}
export function useDeviceToken(): UseDeviceTokenReturn {
const [token, setToken] = useState<string | null>(null);
const [clawId, setClawId] = useState<string | null>(null);
const [name, setName] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const init = async () => {
// Check cache first
const cached = getCachedData();
if (cached) {
setToken(cached.apiKey);
setClawId(cached.clawId);
setName(cached.name);
setIsLoading(false);
return;
}
// Register new device
try {
const deviceId = generateUUID();
const deviceInfo = getDeviceInfo();
const res = await fetch("/api/v1/device/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
deviceId,
...deviceInfo,
}),
});
if (!res.ok) {
throw new Error(`Registration failed: ${res.status}`);
}
const data = await res.json();
const tokenData: DeviceTokenData = {
clawId: data.clawId,
apiKey: data.apiKey,
name: data.name,
deviceId,
};
setCachedData(tokenData);
setToken(data.apiKey);
setClawId(data.clawId);
setName(data.name);
} catch (err) {
setError(err instanceof Error ? err.message : "Registration failed");
} finally {
setIsLoading(false);
}
};
init();
}, []);
const updateName = useCallback(
async (newName: string): Promise<boolean> => {
if (!token) return false;
try {
const res = await fetch("/api/v1/device/name", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name: newName }),
});
if (!res.ok) return false;
setName(newName);
updateCachedName(newName);
return true;
} catch {
return false;
}
},
[token]
);
return { token, clawId, name, isLoading, error, updateName };
}

View File

@@ -11,9 +11,12 @@ import {
} from "drizzle-orm/mysql-core"; } from "drizzle-orm/mysql-core";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
export const claws = mysqlTable("claws", { export const claws = mysqlTable(
"claws",
{
id: varchar("id", { length: 21 }).primaryKey(), id: varchar("id", { length: 21 }).primaryKey(),
apiKey: varchar("api_key", { length: 64 }).notNull().unique(), apiKey: varchar("api_key", { length: 64 }).notNull().unique(),
deviceId: varchar("device_id", { length: 64 }),
name: varchar("name", { length: 100 }).notNull(), name: varchar("name", { length: 100 }).notNull(),
platform: varchar("platform", { length: 20 }), platform: varchar("platform", { length: 20 }),
model: varchar("model", { length: 50 }), model: varchar("model", { length: 50 }),
@@ -28,7 +31,9 @@ export const claws = mysqlTable("claws", {
totalTasks: int("total_tasks").default(0), totalTasks: int("total_tasks").default(0),
createdAt: datetime("created_at").default(sql`NOW()`), createdAt: datetime("created_at").default(sql`NOW()`),
updatedAt: datetime("updated_at").default(sql`NOW()`), updatedAt: datetime("updated_at").default(sql`NOW()`),
}); },
(table) => [uniqueIndex("claws_device_id_unq").on(table.deviceId)]
);
export const heartbeats = mysqlTable( export const heartbeats = mysqlTable(
"heartbeats", "heartbeats",

View File

@@ -27,7 +27,26 @@ export const tokenSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
}); });
export const deviceRegisterSchema = z.object({
deviceId: z.string().min(1).max(64),
name: z.string().min(1).max(100).optional(),
platform: z.string().max(50).optional(),
browser: z.string().max(100).optional(),
screen: z.string().max(20).optional(),
language: z.string().max(10).optional(),
});
export const updateNameSchema = z.object({
name: z
.string()
.min(1)
.max(30)
.regex(/^[A-Za-z0-9_-]+$/, "Only alphanumeric, hyphens, and underscores"),
});
export type RegisterInput = z.infer<typeof registerSchema>; export type RegisterInput = z.infer<typeof registerSchema>;
export type HeartbeatInput = z.infer<typeof heartbeatSchema>; export type HeartbeatInput = z.infer<typeof heartbeatSchema>;
export type TaskInput = z.infer<typeof taskSchema>; export type TaskInput = z.infer<typeof taskSchema>;
export type TokenInput = z.infer<typeof tokenSchema>; export type TokenInput = z.infer<typeof tokenSchema>;
export type DeviceRegisterInput = z.infer<typeof deviceRegisterSchema>;
export type UpdateNameInput = z.infer<typeof updateNameSchema>;

View File

@@ -17,12 +17,29 @@
"live": "Live" "live": "Live"
}, },
"installBanner": { "installBanner": {
"title": "Join the Heatmap", "title": "Register Your Agent",
"subtitle": "Copy and paste this into Claude Code to get started", "subtitle": "Get your agent on the global map in seconds",
"copyTooltip": "Copy prompt to clipboard", "tabOpenClaw": "OpenClaw",
"tabTerminal": "Terminal",
"openclawPrompt": "Help me join the OpenClaw global heatmap! Run this install command in the terminal: bash <(curl -fsSL https://kymr.top/install.sh) --token {token}",
"terminalCommand": "bash <(curl -fsSL https://kymr.top/install.sh) --token {token}",
"copyButton": "Copy", "copyButton": "Copy",
"copied": "Copied!", "copied": "Copied!",
"prompt": "Help me join the OpenClaw global heatmap! First, search for the \"openclaw-reporter\" skill on ClawHub and install it. After installation, help me pick a cool name for my claw (like CoolClaw, NightCrawler, etc.) and guide me through the registration and permission setup." "copyPrompt": "Copy prompt",
"copyCommand": "Copy command",
"openclawHint": "Paste this prompt into your OpenClaw session",
"terminalHint": "Run this command in your terminal",
"yourClaw": "Your Claw",
"editName": "Edit",
"saveName": "Save",
"cancelEdit": "Cancel",
"nameUpdated": "Name updated!",
"nameError": "Failed to update name",
"tokenLabel": "Token",
"showToken": "Show",
"hideToken": "Hide",
"generatingToken": "Generating your token...",
"tokenError": "Failed to generate token. Please refresh."
}, },
"stats": { "stats": {
"onlineNow": "Online Now", "onlineNow": "Online Now",
@@ -90,26 +107,26 @@
"close": "Close" "close": "Close"
}, },
"skillGuide": { "skillGuide": {
"ariaLabel": "OpenClaw Reporter Skill Installation Guide for AI Agents", "ariaLabel": "OpenClaw Installation Guide for AI Agents",
"summaryLabel": "AI Agent Installation Guide (click to expand)", "summaryLabel": "AI Agent Installation Guide (click to expand)",
"whatTitle": "What is OpenClaw Reporter?", "whatTitle": "What is OpenClaw?",
"whatDescription": "openclaw-reporter is a Claude Code skill that lets you participate in the OpenClaw global heatmap at https://kymr.top/. It sends anonymous, non-identifying heartbeats (platform + model only) and generic task summaries. No file paths, code, tool names, or project-specific data is ever sent.", "whatDescription": "OpenClaw lets you participate in the global claw activity heatmap at https://kymr.top/. A lightweight install script sets up automated heartbeats and token usage reporting. No file paths, code, or project-specific data is ever sent.",
"installTitle": "Quick Install (4 Steps)", "installTitle": "Quick Install",
"step1": "Run \"clawhub install openclaw-reporter\" in your terminal, or use \"/clawhub install openclaw-reporter\" inside a Claude Code session.", "step1": "Visit https://kymr.top/ to get your unique token.",
"step2": "The skill will ask for your consent before registering — agree to allow anonymous heartbeats and task summaries to be sent to https://kymr.top/.", "step2": "Run: bash <(curl -fsSL https://kymr.top/install.sh) --token YOUR_TOKEN",
"step3": "Pick a name for your claw (your display name on the global heatmap, e.g. CoolClaw, NightCrawler, CodeClaw).", "step3": "The script installs the CLI, sets up heartbeat monitoring (every 5 min), and token reporting (every 2 hours).",
"step4": "Done! Your claw will appear on the heatmap. Heartbeats are sent automatically via hooks (rate-limited to once per 3 minutes), and a task summary is sent when your session ends.", "step4": "Done! Your claw appears on the heatmap automatically.",
"commandTitle": "Install Command", "commandTitle": "Install Command",
"dataTitle": "Data Collected", "dataTitle": "Data Collected",
"data1": "Claw Name — your chosen display name (e.g. CoolClaw42)", "data1": "Claw Name — your display name on the heatmap",
"data2": "Platform — OS type (darwin, linux, win32)", "data2": "Platform — OS type (darwin, linux, win32)",
"data3": "Model — Claude model identifier (e.g. claude-sonnet-4-6)", "data3": "Model — AI model identifier",
"data4": "Task summary — generic activity indicator (\"Completed a task\")", "data4": "Token usage — daily input/output token counts",
"dataNever": "Never sent: usernames, file paths, code, project names, tool names, secrets, or any identifying information.", "dataNever": "Never sent: usernames, file paths, code, project names, tool names, secrets, or any identifying information.",
"permissionsTitle": "Permissions Required", "permissionsTitle": "What Gets Installed",
"perm1": "Network access — to send heartbeats and task reports to https://kymr.top/", "perm1": "claw-market CLI — lightweight command-line tool",
"perm2": "Write ~/.openclaw/ — to store config (API key) and hook scripts locally", "perm2": "~/.openclaw/ — config file and helper scripts",
"perm3": "Binaries: curl, python3, uname — used by hook scripts" "perm3": "Cron jobs — heartbeat check (5 min) and token report (2 hr)"
}, },
"languageSwitcher": { "languageSwitcher": {
"en": "EN", "en": "EN",

View File

@@ -17,12 +17,29 @@
"live": "实时" "live": "实时"
}, },
"installBanner": { "installBanner": {
"title": "加入热力图", "title": "注册你的 Agent",
"subtitle": "复制下面这段话,粘贴到 Claude Code 对话中即可开始", "subtitle": "几秒内让你的 Agent 出现在全球地图上",
"copyTooltip": "复制到剪贴板", "tabOpenClaw": "OpenClaw",
"tabTerminal": "终端安装",
"openclawPrompt": "帮我加入 OpenClaw 全球龙虾热力图在终端执行这个安装命令bash <(curl -fsSL https://kymr.top/install.sh) --token {token}",
"terminalCommand": "bash <(curl -fsSL https://kymr.top/install.sh) --token {token}",
"copyButton": "复制", "copyButton": "复制",
"copied": "已复制!", "copied": "已复制!",
"prompt": "帮我加入 OpenClaw 全球龙虾热力图!首先,在 ClawHub 上搜索并安装 \"openclaw-reporter\" 技能。安装完成后,帮我给我的小龙虾起一个酷炫的名字(比如 酷龙虾、暗夜龙虾、代码龙虾 等),然后引导我完成注册和权限授权。" "copyPrompt": "复制提示词",
"copyCommand": "复制命令",
"openclawHint": "将此提示词粘贴到你的 OpenClaw 会话中",
"terminalHint": "在终端中运行此命令",
"yourClaw": "你的龙虾",
"editName": "编辑",
"saveName": "保存",
"cancelEdit": "取消",
"nameUpdated": "名称已更新!",
"nameError": "更新名称失败",
"tokenLabel": "令牌",
"showToken": "显示",
"hideToken": "隐藏",
"generatingToken": "正在生成你的令牌...",
"tokenError": "生成令牌失败,请刷新页面。"
}, },
"stats": { "stats": {
"onlineNow": "当前在线", "onlineNow": "当前在线",
@@ -90,26 +107,26 @@
"close": "关闭" "close": "关闭"
}, },
"skillGuide": { "skillGuide": {
"ariaLabel": "OpenClaw Reporter 技能安装指南(供 AI 代理阅读)", "ariaLabel": "OpenClaw 安装指南(供 AI 代理阅读)",
"summaryLabel": "AI 代理安装指南(点击展开)", "summaryLabel": "AI 代理安装指南(点击展开)",
"whatTitle": "什么是 OpenClaw Reporter", "whatTitle": "什么是 OpenClaw",
"whatDescription": "openclaw-reporter 是一个 Claude Code 技能,让你参与 OpenClaw 全球热力图https://kymr.top/)。它只发送匿名的、非身份识别的心跳(操作系统 + 模型信息)和通用任务摘要。绝不发送文件路径、代码、工具名称或任何项目相关数据。", "whatDescription": "OpenClaw 让你参与全球龙虾活动热力图https://kymr.top/)。轻量安装脚本自动设置心跳监控和 token 用量上报。绝不发送文件路径、代码或任何项目相关数据。",
"installTitle": "快速安装4 步)", "installTitle": "快速安装",
"step1": "在终端运行 \"clawhub install openclaw-reporter\",或在 Claude Code 对话中输入 \"/clawhub install openclaw-reporter\"。", "step1": "访问 https://kymr.top/ 获取你的专属令牌。",
"step2": "技能会在注册前征求你的同意——同意允许向 https://kymr.top/ 发送匿名心跳和任务摘要。", "step2": "运行bash <(curl -fsSL https://kymr.top/install.sh) --token 你的令牌",
"step3": "给你的小龙虾起一个名字(你在全球热力图上的显示名称,比如 酷龙虾、暗夜龙虾、代码龙虾)。", "step3": "脚本会安装 CLI 工具,设置心跳监控(每 5 分钟)和 token 上报(每 2 小时)。",
"step4": "完成!你的龙虾会出现在热力图上。心跳通过 hooks 自动发送(限制为每 3 分钟最多一次),会话结束时会发送一条任务摘要。", "step4": "完成!你的龙虾会自动出现在热力图上。",
"commandTitle": "安装命令", "commandTitle": "安装命令",
"dataTitle": "收集的数据", "dataTitle": "收集的数据",
"data1": "龙虾名称——你选择的显示名称(如 酷龙虾42", "data1": "龙虾名称——你在热力图上的显示名称",
"data2": "操作系统——系统类型darwin、linux、win32", "data2": "操作系统——系统类型darwin、linux、win32",
"data3": "模型——Claude 模型标识符(如 claude-sonnet-4-6", "data3": "模型——AI 模型标识符",
"data4": "任务摘要——通用活动指标(\"Completed a task\"", "data4": "Token 用量——每日输入/输出 token 数量",
"dataNever": "绝不发送:用户名、文件路径、代码、项目名称、工具名称、密钥或任何身份识别信息。", "dataNever": "绝不发送:用户名、文件路径、代码、项目名称、工具名称、密钥或任何身份识别信息。",
"permissionsTitle": "所需权限", "permissionsTitle": "安装内容",
"perm1": "网络访问——向 https://kymr.top/ 发送心跳和任务报告", "perm1": "claw-market CLI——轻量命令行工具",
"perm2": "写入 ~/.openclaw/——在本地存储配置API 密钥)和 hook 脚本", "perm2": "~/.openclaw/——配置文件和辅助脚本",
"perm3": "系统工具curl、python3、uname——供 hook 脚本使用" "perm3": "定时任务——心跳检测5 分钟)和 token 上报2 小时)"
}, },
"languageSwitcher": { "languageSwitcher": {
"en": "EN", "en": "EN",

465
package-lock.json generated
View File

@@ -47,6 +47,7 @@
"eslint-config-next": "^15.3.0", "eslint-config-next": "^15.3.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"tailwindcss": "^4.1.0", "tailwindcss": "^4.1.0",
"tsx": "^4.21.0",
"typescript": "^5.8.0" "typescript": "^5.8.0"
} }
}, },
@@ -8316,6 +8317,17 @@
} }
} }
}, },
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.19",
"resolved": "https://mirrors.tencent.com/npm/@swc/helpers/-/helpers-0.5.19.tgz",
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next/node_modules/nanoid": { "node_modules/next/node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -10335,6 +10347,459 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://mirrors.tencent.com/npm/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-x64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/esbuild": {
"version": "0.27.4",
"resolved": "https://mirrors.tencent.com/npm/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

291
public/install.sh Normal file
View File

@@ -0,0 +1,291 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================================
# OpenClaw Market — One-click installer
# Usage: bash <(curl -fsSL https://kymr.top/install.sh) --token <TOKEN>
# ============================================================================
VERSION="1.0.0"
API_ENDPOINT="https://kymr.top/api/v1"
CONFIG_DIR="$HOME/.openclaw"
CONFIG_FILE="$CONFIG_DIR/config.json"
HEARTBEAT_SCRIPT="$CONFIG_DIR/heartbeat-check.sh"
TRACK_TOKENS_SCRIPT="$CONFIG_DIR/track-tokens.sh"
REPORT_TOKENS_SCRIPT="$CONFIG_DIR/report-tokens.sh"
TOKEN_USAGE_FILE="$CONFIG_DIR/token-usage.json"
CRON_TAG="# openclaw-market"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${GREEN}[✓]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
error() { echo -e "${RED}[✗]${NC} $1"; }
step() { echo -e "${CYAN}[→]${NC} $1"; }
# ── Parse arguments ─────────────────────────────────────────────────────────
TOKEN=""
UNINSTALL=false
while [[ $# -gt 0 ]]; do
case "$1" in
--token) TOKEN="$2"; shift 2 ;;
--token=*) TOKEN="${1#*=}"; shift ;;
--uninstall) UNINSTALL=true; shift ;;
--help|-h)
echo "Usage: bash install.sh --token <TOKEN>"
echo ""
echo "Options:"
echo " --token <TOKEN> Your OpenClaw token (get it from https://kymr.top)"
echo " --uninstall Remove OpenClaw and all cron jobs"
echo " --help Show this help"
exit 0
;;
*) error "Unknown option: $1"; exit 1 ;;
esac
done
# ── Uninstall ───────────────────────────────────────────────────────────────
if [ "$UNINSTALL" = true ]; then
step "Uninstalling OpenClaw..."
# Remove cron jobs
crontab -l 2>/dev/null | grep -v "$CRON_TAG" | crontab - 2>/dev/null || true
info "Cron jobs removed"
# Remove config directory
if [ -d "$CONFIG_DIR" ]; then
rm -rf "$CONFIG_DIR"
info "Config directory removed: $CONFIG_DIR"
fi
# Optionally remove CLI
if command -v claw-market &>/dev/null; then
warn "CLI 'claw-market' is still installed. Run 'npm uninstall -g @ricardweii/claw-market' to remove it."
fi
info "OpenClaw uninstalled successfully!"
exit 0
fi
# ── Validate token ──────────────────────────────────────────────────────────
if [ -z "$TOKEN" ]; then
error "Token is required. Usage: bash install.sh --token <YOUR_TOKEN>"
echo ""
echo "Get your token at https://kymr.top"
exit 1
fi
echo ""
echo -e "${CYAN}🦞 OpenClaw Market Installer v${VERSION}${NC}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# ── Check prerequisites ────────────────────────────────────────────────────
step "Checking prerequisites..."
if ! command -v node &>/dev/null; then
error "Node.js is required but not installed."
echo " Install it from https://nodejs.org/ or use nvm:"
echo " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash"
exit 1
fi
info "Node.js $(node --version) found"
if ! command -v npm &>/dev/null; then
error "npm is required but not installed."
exit 1
fi
info "npm $(npm --version) found"
# ── Install CLI ─────────────────────────────────────────────────────────────
step "Installing claw-market CLI..."
if command -v claw-market &>/dev/null; then
info "claw-market CLI already installed"
else
npm install -g @ricardweii/claw-market 2>/dev/null || {
warn "Global install failed, trying with sudo..."
sudo npm install -g @ricardweii/claw-market
}
info "claw-market CLI installed"
fi
# ── Fetch claw info from server ─────────────────────────────────────────────
step "Verifying token with server..."
CLAW_INFO=$(curl -sf -H "Authorization: Bearer $TOKEN" "${API_ENDPOINT}/heartbeat" \
-X POST -H "Content-Type: application/json" -d '{}' 2>/dev/null) || {
error "Invalid token or server unreachable."
echo " Check your token and try again."
exit 1
}
info "Token verified successfully"
# ── Write config ────────────────────────────────────────────────────────────
step "Writing configuration..."
mkdir -p "$CONFIG_DIR"
chmod 700 "$CONFIG_DIR"
cat > "$CONFIG_FILE" << EOF
{
"apiKey": "$TOKEN",
"endpoint": "$API_ENDPOINT"
}
EOF
chmod 600 "$CONFIG_FILE"
info "Config written to $CONFIG_FILE"
# ── Create heartbeat check script ──────────────────────────────────────────
step "Setting up heartbeat checker..."
cat > "$HEARTBEAT_SCRIPT" << 'HEARTBEAT_EOF'
#!/usr/bin/env bash
# OpenClaw heartbeat checker — runs every 5 minutes via cron
# Checks if openclaw process is running, sends heartbeat if so
export PATH="$HOME/.nvm/versions/node/$(ls "$HOME/.nvm/versions/node/" 2>/dev/null | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
if pgrep -f "openclaw" > /dev/null 2>&1; then
claw-market heartbeat 2>/dev/null || true
fi
HEARTBEAT_EOF
chmod +x "$HEARTBEAT_SCRIPT"
info "Heartbeat checker created"
# ── Create token tracking script ───────────────────────────────────────────
step "Setting up token tracker..."
cat > "$TRACK_TOKENS_SCRIPT" << 'TRACK_EOF'
#!/usr/bin/env bash
# OpenClaw token tracker — call with: track-tokens.sh <input> <output>
# Accumulates daily token usage in ~/.openclaw/token-usage.json
CONFIG_DIR="$HOME/.openclaw"
TOKEN_FILE="$CONFIG_DIR/token-usage.json"
TODAY=$(date +%Y-%m-%d)
INPUT=${1:-0}
OUTPUT=${2:-0}
# Validate numeric
if ! [[ "$INPUT" =~ ^[0-9]+$ ]] || ! [[ "$OUTPUT" =~ ^[0-9]+$ ]]; then
echo "Usage: track-tokens.sh <input_tokens> <output_tokens>"
exit 1
fi
# Read existing data or init
if [ -f "$TOKEN_FILE" ]; then
EXISTING_DATE=$(python3 -c "import json; d=json.load(open('$TOKEN_FILE')); print(d.get('date',''))" 2>/dev/null || echo "")
if [ "$EXISTING_DATE" = "$TODAY" ]; then
EXISTING_INPUT=$(python3 -c "import json; d=json.load(open('$TOKEN_FILE')); print(d.get('inputTokens',0))" 2>/dev/null || echo "0")
EXISTING_OUTPUT=$(python3 -c "import json; d=json.load(open('$TOKEN_FILE')); print(d.get('outputTokens',0))" 2>/dev/null || echo "0")
INPUT=$((INPUT + EXISTING_INPUT))
OUTPUT=$((OUTPUT + EXISTING_OUTPUT))
fi
fi
# Write updated data
cat > "$TOKEN_FILE" << EOF
{
"date": "$TODAY",
"inputTokens": $INPUT,
"outputTokens": $OUTPUT,
"updatedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
echo "Token usage updated: input=$INPUT, output=$OUTPUT (date=$TODAY)"
TRACK_EOF
chmod +x "$TRACK_TOKENS_SCRIPT"
info "Token tracker created"
# ── Create token reporter script ───────────────────────────────────────────
step "Setting up token reporter..."
cat > "$REPORT_TOKENS_SCRIPT" << 'REPORT_EOF'
#!/usr/bin/env bash
# OpenClaw token reporter — runs every 2 hours via cron
# Reads accumulated token usage and reports to server
export PATH="$HOME/.nvm/versions/node/$(ls "$HOME/.nvm/versions/node/" 2>/dev/null | tail -1)/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
CONFIG_DIR="$HOME/.openclaw"
TOKEN_FILE="$CONFIG_DIR/token-usage.json"
TODAY=$(date +%Y-%m-%d)
# Check if token usage file exists
if [ ! -f "$TOKEN_FILE" ]; then
exit 0
fi
# Read data
DATE=$(python3 -c "import json; d=json.load(open('$TOKEN_FILE')); print(d.get('date',''))" 2>/dev/null || echo "")
INPUT=$(python3 -c "import json; d=json.load(open('$TOKEN_FILE')); print(d.get('inputTokens',0))" 2>/dev/null || echo "0")
OUTPUT=$(python3 -c "import json; d=json.load(open('$TOKEN_FILE')); print(d.get('outputTokens',0))" 2>/dev/null || echo "0")
# Only report if there's data and it's for today
if [ "$INPUT" = "0" ] && [ "$OUTPUT" = "0" ]; then
exit 0
fi
# Report via CLI
if [ -n "$DATE" ]; then
claw-market token "$INPUT" "$OUTPUT" --date "$DATE" 2>/dev/null || true
else
claw-market token "$INPUT" "$OUTPUT" 2>/dev/null || true
fi
REPORT_EOF
chmod +x "$REPORT_TOKENS_SCRIPT"
info "Token reporter created"
# ── Set up cron jobs ────────────────────────────────────────────────────────
step "Setting up scheduled tasks..."
# Remove existing openclaw cron jobs
EXISTING_CRON=$(crontab -l 2>/dev/null | grep -v "$CRON_TAG" || true)
# Add new cron jobs
NEW_CRON="$EXISTING_CRON
*/5 * * * * $HEARTBEAT_SCRIPT $CRON_TAG
7 */2 * * * $REPORT_TOKENS_SCRIPT $CRON_TAG"
echo "$NEW_CRON" | crontab -
info "Cron jobs installed:"
echo " - Heartbeat check: every 5 minutes"
echo " - Token report: every 2 hours"
# ── Send initial heartbeat ──────────────────────────────────────────────────
step "Sending initial heartbeat..."
claw-market heartbeat 2>/dev/null && {
info "Initial heartbeat sent"
} || {
warn "Initial heartbeat failed (non-blocking)"
}
# ── Done ────────────────────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN}🦞 OpenClaw installed successfully!${NC}"
echo ""
echo " Your claw is now active on the heatmap."
echo " View it at: https://kymr.top"
echo ""
echo " Scripts installed:"
echo " $HEARTBEAT_SCRIPT — heartbeat (every 5 min)"
echo " $TRACK_TOKENS_SCRIPT — track token usage"
echo " $REPORT_TOKENS_SCRIPT — report tokens (every 2 hr)"
echo ""
echo " To track tokens manually:"
echo " ~/.openclaw/track-tokens.sh <input> <output>"
echo ""
echo " To uninstall:"
echo " bash <(curl -fsSL https://kymr.top/install.sh) --uninstall"
echo ""