clawhq 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/index.ts +532 -0
- package/openclaw.plugin.json +15 -0
- package/package.json +49 -0
- package/ui/404/index.html +1 -0
- package/ui/404.html +1 -0
- package/ui/__next.__PAGE__.txt +9 -0
- package/ui/__next._full.txt +23 -0
- package/ui/__next._head.txt +6 -0
- package/ui/__next._index.txt +8 -0
- package/ui/__next._tree.txt +4 -0
- package/ui/_next/static/DWx70nlvHGBzpFTu3Sxz5/_buildManifest.js +11 -0
- package/ui/_next/static/DWx70nlvHGBzpFTu3Sxz5/_clientMiddlewareManifest.json +1 -0
- package/ui/_next/static/DWx70nlvHGBzpFTu3Sxz5/_ssgManifest.js +1 -0
- package/ui/_next/static/chunks/05d833979760004b.js +1 -0
- package/ui/_next/static/chunks/0bd6498bda341889.js +1 -0
- package/ui/_next/static/chunks/0ce1bd3a49c9d2ee.js +1 -0
- package/ui/_next/static/chunks/0d1bcf8db95909b4.js +5 -0
- package/ui/_next/static/chunks/0e8dd92e1773b206.js +2 -0
- package/ui/_next/static/chunks/1d68ba38700c14af.js +1 -0
- package/ui/_next/static/chunks/1df4b55f9e090d79.js +1 -0
- package/ui/_next/static/chunks/25affa88b4c38de5.js +1 -0
- package/ui/_next/static/chunks/390d2f0ada7fcb51.js +1 -0
- package/ui/_next/static/chunks/66776cedfcec7fc8.js +1 -0
- package/ui/_next/static/chunks/6750ad6806bf564e.js +1 -0
- package/ui/_next/static/chunks/724cc24d423d0c1d.js +1 -0
- package/ui/_next/static/chunks/73acbb00f03d1647.js +6 -0
- package/ui/_next/static/chunks/74f28e1af7ed7ed3.js +1 -0
- package/ui/_next/static/chunks/7de9141b1af425c3.js +1 -0
- package/ui/_next/static/chunks/9006a9dbed1389eb.js +1 -0
- package/ui/_next/static/chunks/921e8895e15d1eea.css +3 -0
- package/ui/_next/static/chunks/a12c273af4edcf24.js +1 -0
- package/ui/_next/static/chunks/a2af742ffa61520a.js +6 -0
- package/ui/_next/static/chunks/a6dad97d9634a72d.js +1 -0
- package/ui/_next/static/chunks/a6dad97d9634a72d.js.map +1 -0
- package/ui/_next/static/chunks/e7b7178a9129f84d.js +1 -0
- package/ui/_next/static/chunks/f24f5995274b1b19.js +1 -0
- package/ui/_next/static/chunks/f9a7bd29e3728867.js +1 -0
- package/ui/_next/static/chunks/ff1a16fafef87110.js +1 -0
- package/ui/_next/static/chunks/turbopack-9730d705aa1aefde.js +4 -0
- package/ui/_next/static/media/4fa387ec64143e14-s.c1fdd6c2.woff2 +0 -0
- package/ui/_next/static/media/7178b3e590c64307-s.b97b3418.woff2 +0 -0
- package/ui/_next/static/media/797e433ab948586e-s.p.dbea232f.woff2 +0 -0
- package/ui/_next/static/media/8a480f0b521d4e75-s.8e0177b5.woff2 +0 -0
- package/ui/_next/static/media/bbc41e54d2fcbd21-s.799d8ef8.woff2 +0 -0
- package/ui/_next/static/media/caa3a2e1cccd8315-s.p.853070df.woff2 +0 -0
- package/ui/_next/static/media/favicon.0b3bf435.ico +0 -0
- package/ui/_not-found/__next._full.txt +17 -0
- package/ui/_not-found/__next._head.txt +6 -0
- package/ui/_not-found/__next._index.txt +8 -0
- package/ui/_not-found/__next._not-found.__PAGE__.txt +5 -0
- package/ui/_not-found/__next._not-found.txt +4 -0
- package/ui/_not-found/__next._tree.txt +2 -0
- package/ui/_not-found/index.html +1 -0
- package/ui/_not-found/index.txt +17 -0
- package/ui/access/__next._full.txt +23 -0
- package/ui/access/__next._head.txt +6 -0
- package/ui/access/__next._index.txt +8 -0
- package/ui/access/__next._tree.txt +4 -0
- package/ui/access/__next.access.__PAGE__.txt +9 -0
- package/ui/access/__next.access.txt +4 -0
- package/ui/access/index.html +1 -0
- package/ui/access/index.txt +23 -0
- package/ui/activity/__next._full.txt +23 -0
- package/ui/activity/__next._head.txt +6 -0
- package/ui/activity/__next._index.txt +8 -0
- package/ui/activity/__next._tree.txt +4 -0
- package/ui/activity/__next.activity.__PAGE__.txt +9 -0
- package/ui/activity/__next.activity.txt +4 -0
- package/ui/activity/index.html +1 -0
- package/ui/activity/index.txt +23 -0
- package/ui/calendar/__next._full.txt +23 -0
- package/ui/calendar/__next._head.txt +6 -0
- package/ui/calendar/__next._index.txt +8 -0
- package/ui/calendar/__next._tree.txt +4 -0
- package/ui/calendar/__next.calendar.__PAGE__.txt +9 -0
- package/ui/calendar/__next.calendar.txt +4 -0
- package/ui/calendar/index.html +1 -0
- package/ui/calendar/index.txt +23 -0
- package/ui/costs/__next._full.txt +23 -0
- package/ui/costs/__next._head.txt +6 -0
- package/ui/costs/__next._index.txt +8 -0
- package/ui/costs/__next._tree.txt +4 -0
- package/ui/costs/__next.costs.__PAGE__.txt +9 -0
- package/ui/costs/__next.costs.txt +4 -0
- package/ui/costs/index.html +1 -0
- package/ui/costs/index.txt +23 -0
- package/ui/cron/__next._full.txt +23 -0
- package/ui/cron/__next._head.txt +6 -0
- package/ui/cron/__next._index.txt +8 -0
- package/ui/cron/__next._tree.txt +4 -0
- package/ui/cron/__next.cron.__PAGE__.txt +9 -0
- package/ui/cron/__next.cron.txt +4 -0
- package/ui/cron/index.html +1 -0
- package/ui/cron/index.txt +23 -0
- package/ui/favicon.ico +0 -0
- package/ui/file.svg +1 -0
- package/ui/globe.svg +1 -0
- package/ui/index.html +1 -0
- package/ui/index.txt +23 -0
- package/ui/memory/__next._full.txt +23 -0
- package/ui/memory/__next._head.txt +6 -0
- package/ui/memory/__next._index.txt +8 -0
- package/ui/memory/__next._tree.txt +4 -0
- package/ui/memory/__next.memory.__PAGE__.txt +9 -0
- package/ui/memory/__next.memory.txt +4 -0
- package/ui/memory/index.html +1 -0
- package/ui/memory/index.txt +23 -0
- package/ui/next.svg +1 -0
- package/ui/planning/__next._full.txt +23 -0
- package/ui/planning/__next._head.txt +6 -0
- package/ui/planning/__next._index.txt +8 -0
- package/ui/planning/__next._tree.txt +4 -0
- package/ui/planning/__next.planning.__PAGE__.txt +9 -0
- package/ui/planning/__next.planning.txt +4 -0
- package/ui/planning/index.html +1 -0
- package/ui/planning/index.txt +23 -0
- package/ui/settings/__next._full.txt +23 -0
- package/ui/settings/__next._head.txt +6 -0
- package/ui/settings/__next._index.txt +8 -0
- package/ui/settings/__next._tree.txt +4 -0
- package/ui/settings/__next.settings.__PAGE__.txt +9 -0
- package/ui/settings/__next.settings.txt +4 -0
- package/ui/settings/index.html +1 -0
- package/ui/settings/index.txt +23 -0
- package/ui/skills/__next._full.txt +23 -0
- package/ui/skills/__next._head.txt +6 -0
- package/ui/skills/__next._index.txt +8 -0
- package/ui/skills/__next._tree.txt +4 -0
- package/ui/skills/__next.skills.__PAGE__.txt +9 -0
- package/ui/skills/__next.skills.txt +4 -0
- package/ui/skills/index.html +1 -0
- package/ui/skills/index.txt +23 -0
- package/ui/vercel.svg +1 -0
- package/ui/window.svg +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# 🦞 ClawHQ
|
|
2
|
+
|
|
3
|
+
Owner-centric dashboard for managing AI agents. Built for [OpenClaw](https://github.com/openclaw/openclaw).
|
|
4
|
+
|
|
5
|
+
## Vision
|
|
6
|
+
|
|
7
|
+
ClawHQ is the missing tool for humans who delegate work to AI agents. Not developer observability — **owner productivity**.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
### Core (MVP)
|
|
12
|
+
- **📊 Cost Dashboard** — Track daily/weekly/monthly spend, per-model breakdown
|
|
13
|
+
- **⏰ Cron Manager** — Visual editor for scheduled jobs, run history, quick actions
|
|
14
|
+
- **📋 Task Board** — Kanban view of delegated work: backlog → in progress → done
|
|
15
|
+
|
|
16
|
+
### Coming Soon
|
|
17
|
+
- 💬 Built-in chat with threading
|
|
18
|
+
- 🔌 Connected apps visibility
|
|
19
|
+
- 🤖 Multi-agent switching
|
|
20
|
+
- 📈 Productivity metrics
|
|
21
|
+
|
|
22
|
+
## Tech Stack
|
|
23
|
+
|
|
24
|
+
- **Framework:** Next.js 14+ (App Router)
|
|
25
|
+
- **Styling:** Tailwind CSS + shadcn/ui
|
|
26
|
+
- **Backend:** Connects to OpenClaw via HTTP API
|
|
27
|
+
- **Real-time:** OpenClaw hooks for live updates
|
|
28
|
+
|
|
29
|
+
## Getting Started
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Install dependencies
|
|
33
|
+
npm install
|
|
34
|
+
|
|
35
|
+
# Run development server
|
|
36
|
+
npm run dev
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Open [http://localhost:3000](http://localhost:3000).
|
|
40
|
+
|
|
41
|
+
## OpenClaw Integration
|
|
42
|
+
|
|
43
|
+
ClawHQ connects to your OpenClaw gateway to fetch:
|
|
44
|
+
- Session data and history
|
|
45
|
+
- Cron job configuration
|
|
46
|
+
- Usage statistics
|
|
47
|
+
- Agent status
|
|
48
|
+
|
|
49
|
+
Configure the gateway URL in Settings.
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
MIT
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
Built with 🦞 by Bill & Lolo
|
package/index.ts
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
|
|
5
|
+
// Types matching the OpenClaw plugin API
|
|
6
|
+
interface OpenClawPluginApi {
|
|
7
|
+
id: string;
|
|
8
|
+
config: Record<string, any>;
|
|
9
|
+
pluginConfig: Record<string, any>;
|
|
10
|
+
runtime: Record<string, any>;
|
|
11
|
+
logger: { info: (...args: any[]) => void; warn: (...args: any[]) => void; error: (...args: any[]) => void; debug: (...args: any[]) => void };
|
|
12
|
+
registerHttpRoute: (params: { path: string; handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> | void }) => void;
|
|
13
|
+
registerHttpHandler: (handler: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>) => void;
|
|
14
|
+
registerGatewayMethod: (method: string, handler: (ctx: { params: any; respond: (ok: boolean, payload?: unknown, error?: unknown) => void }) => Promise<void> | void) => void;
|
|
15
|
+
resolvePath: (input: string) => string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ─── Helpers ───
|
|
19
|
+
|
|
20
|
+
function resolveWorkspaceDir(config: Record<string, any>): string {
|
|
21
|
+
// Try config.agents.defaults.workspace, fallback to ~/.openclaw/workspace
|
|
22
|
+
const ws = config?.agents?.defaults?.workspace;
|
|
23
|
+
if (ws && typeof ws === "string") {
|
|
24
|
+
if (ws.startsWith("~")) return path.join(process.env.HOME || "/root", ws.slice(1));
|
|
25
|
+
return ws;
|
|
26
|
+
}
|
|
27
|
+
return path.join(process.env.HOME || "/root", ".openclaw", "workspace");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveUiDir(): string {
|
|
31
|
+
// UI files live alongside this plugin
|
|
32
|
+
return path.join(__dirname, "ui");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MIME_TYPES: Record<string, string> = {
|
|
36
|
+
".html": "text/html",
|
|
37
|
+
".js": "application/javascript",
|
|
38
|
+
".css": "text/css",
|
|
39
|
+
".json": "application/json",
|
|
40
|
+
".png": "image/png",
|
|
41
|
+
".svg": "image/svg+xml",
|
|
42
|
+
".ico": "image/x-icon",
|
|
43
|
+
".woff": "font/woff",
|
|
44
|
+
".woff2": "font/woff2",
|
|
45
|
+
".txt": "text/plain",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function sendJson(res: ServerResponse, status: number, data: unknown) {
|
|
49
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
50
|
+
res.end(JSON.stringify(data));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sendError(res: ServerResponse, status: number, message: string) {
|
|
54
|
+
sendJson(res, status, { error: message });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getBody(req: IncomingMessage): Promise<string> {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
let body = "";
|
|
60
|
+
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
|
61
|
+
req.on("end", () => resolve(body));
|
|
62
|
+
req.on("error", reject);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Verify auth token from query param or Authorization header
|
|
67
|
+
function checkAuth(req: IncomingMessage, config: Record<string, any>): boolean {
|
|
68
|
+
const authToken = config?.gateway?.auth?.token;
|
|
69
|
+
if (!authToken) return true; // No auth configured
|
|
70
|
+
|
|
71
|
+
// Check query param
|
|
72
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
73
|
+
const queryToken = url.searchParams.get("token");
|
|
74
|
+
if (queryToken === authToken) return true;
|
|
75
|
+
|
|
76
|
+
// Check Authorization header
|
|
77
|
+
const authHeader = req.headers.authorization;
|
|
78
|
+
if (authHeader) {
|
|
79
|
+
const bearer = authHeader.replace(/^Bearer\s+/i, "");
|
|
80
|
+
if (bearer === authToken) return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check cookie
|
|
84
|
+
const cookies = req.headers.cookie || "";
|
|
85
|
+
const tokenCookie = cookies.split(";").map(c => c.trim()).find(c => c.startsWith("clawhq_token="));
|
|
86
|
+
if (tokenCookie) {
|
|
87
|
+
const cookieVal = tokenCookie.split("=")[1];
|
|
88
|
+
if (cookieVal === authToken) return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Sanitize file path to prevent directory traversal
|
|
95
|
+
function sanitizePath(input: string): string | null {
|
|
96
|
+
const normalized = path.normalize(input).replace(/^(\.\.[\/\\])+/, "");
|
|
97
|
+
if (normalized.includes("..")) return null;
|
|
98
|
+
if (path.isAbsolute(normalized)) return null;
|
|
99
|
+
return normalized;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Helpers: check if binary exists on PATH ───
|
|
103
|
+
|
|
104
|
+
function binExistsOnPath(name: string): boolean {
|
|
105
|
+
const pathDirs = (process.env.PATH || "").split(path.delimiter);
|
|
106
|
+
for (const dir of pathDirs) {
|
|
107
|
+
try {
|
|
108
|
+
const full = path.join(dir, name);
|
|
109
|
+
fs.accessSync(full, fs.constants.X_OK);
|
|
110
|
+
return true;
|
|
111
|
+
} catch { /* not found */ }
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Plugin Registration ───
|
|
117
|
+
|
|
118
|
+
export default function register(api: OpenClawPluginApi) {
|
|
119
|
+
const log = api.logger;
|
|
120
|
+
const config = api.config;
|
|
121
|
+
const basePath = api.pluginConfig?.basePath || "/clawhq";
|
|
122
|
+
const workspaceDir = resolveWorkspaceDir(config);
|
|
123
|
+
const uiDir = resolveUiDir();
|
|
124
|
+
|
|
125
|
+
log.info(`ClawHQ plugin loaded — basePath=${basePath}, workspace=${workspaceDir}, ui=${uiDir}`);
|
|
126
|
+
|
|
127
|
+
// ─── API: Read workspace file ───
|
|
128
|
+
api.registerHttpRoute({
|
|
129
|
+
path: `${basePath}/api/files`,
|
|
130
|
+
handler: async (req, res) => {
|
|
131
|
+
if (!checkAuth(req, config)) { sendError(res, 401, "Unauthorized"); return; }
|
|
132
|
+
|
|
133
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
134
|
+
const filePath = url.searchParams.get("path");
|
|
135
|
+
if (!filePath) { sendError(res, 400, "Missing ?path= parameter"); return; }
|
|
136
|
+
|
|
137
|
+
const safe = sanitizePath(filePath);
|
|
138
|
+
if (!safe) { sendError(res, 400, "Invalid path"); return; }
|
|
139
|
+
|
|
140
|
+
const fullPath = path.join(workspaceDir, safe);
|
|
141
|
+
try {
|
|
142
|
+
const content = await fs.promises.readFile(fullPath, "utf-8");
|
|
143
|
+
sendJson(res, 200, { path: safe, content });
|
|
144
|
+
} catch {
|
|
145
|
+
sendError(res, 404, `File not found: ${safe}`);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ─── API: Write workspace file ───
|
|
151
|
+
api.registerHttpRoute({
|
|
152
|
+
path: `${basePath}/api/write`,
|
|
153
|
+
handler: async (req, res) => {
|
|
154
|
+
if (!checkAuth(req, config)) { sendError(res, 401, "Unauthorized"); return; }
|
|
155
|
+
if (req.method !== "POST" && req.method !== "PUT") { sendError(res, 405, "Method not allowed"); return; }
|
|
156
|
+
|
|
157
|
+
const body = await getBody(req);
|
|
158
|
+
let parsed: { path: string; content: string };
|
|
159
|
+
try {
|
|
160
|
+
parsed = JSON.parse(body);
|
|
161
|
+
} catch {
|
|
162
|
+
sendError(res, 400, "Invalid JSON body"); return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!parsed.path || typeof parsed.content !== "string") {
|
|
166
|
+
sendError(res, 400, "Body must have { path, content }"); return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const safe = sanitizePath(parsed.path);
|
|
170
|
+
if (!safe) { sendError(res, 400, "Invalid path"); return; }
|
|
171
|
+
|
|
172
|
+
const fullPath = path.join(workspaceDir, safe);
|
|
173
|
+
try {
|
|
174
|
+
await fs.promises.mkdir(path.dirname(fullPath), { recursive: true });
|
|
175
|
+
await fs.promises.writeFile(fullPath, parsed.content, "utf-8");
|
|
176
|
+
sendJson(res, 200, { ok: true, path: safe });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
sendError(res, 500, `Write failed: ${err}`);
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ─── API: List workspace files ───
|
|
184
|
+
api.registerHttpRoute({
|
|
185
|
+
path: `${basePath}/api/ls`,
|
|
186
|
+
handler: async (req, res) => {
|
|
187
|
+
if (!checkAuth(req, config)) { sendError(res, 401, "Unauthorized"); return; }
|
|
188
|
+
|
|
189
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
190
|
+
const dirPath = url.searchParams.get("path") || "";
|
|
191
|
+
|
|
192
|
+
const safe = dirPath ? sanitizePath(dirPath) : "";
|
|
193
|
+
if (safe === null) { sendError(res, 400, "Invalid path"); return; }
|
|
194
|
+
|
|
195
|
+
const fullPath = path.join(workspaceDir, safe || "");
|
|
196
|
+
try {
|
|
197
|
+
const entries = await fs.promises.readdir(fullPath, { withFileTypes: true });
|
|
198
|
+
const files = entries.map(e => ({
|
|
199
|
+
name: e.name,
|
|
200
|
+
path: path.join(safe || "", e.name),
|
|
201
|
+
isDir: e.isDirectory(),
|
|
202
|
+
}));
|
|
203
|
+
sendJson(res, 200, { path: safe || "/", files });
|
|
204
|
+
} catch {
|
|
205
|
+
sendError(res, 404, `Directory not found: ${safe}`);
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ─── Catch-all HTTP handler for UI static files ───
|
|
211
|
+
api.registerHttpHandler(async (req, res) => {
|
|
212
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
213
|
+
let pathname = url.pathname;
|
|
214
|
+
|
|
215
|
+
// Only handle requests under basePath
|
|
216
|
+
if (!pathname.startsWith(basePath)) return false;
|
|
217
|
+
|
|
218
|
+
// Strip basePath
|
|
219
|
+
let relativePath = pathname.slice(basePath.length) || "/";
|
|
220
|
+
|
|
221
|
+
// Auth check for UI pages
|
|
222
|
+
if (!checkAuth(req, config)) {
|
|
223
|
+
// Redirect to gateway root for auth
|
|
224
|
+
res.writeHead(401, { "Content-Type": "text/html" });
|
|
225
|
+
res.end("<h1>Unauthorized</h1><p>Add ?token=YOUR_TOKEN to the URL.</p>");
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Map to file
|
|
230
|
+
if (relativePath === "/") relativePath = "/index.html";
|
|
231
|
+
if (!path.extname(relativePath)) relativePath += ".html";
|
|
232
|
+
|
|
233
|
+
const filePath = path.join(uiDir, relativePath);
|
|
234
|
+
|
|
235
|
+
// Security: ensure we don't escape uiDir
|
|
236
|
+
if (!filePath.startsWith(uiDir)) {
|
|
237
|
+
res.writeHead(403);
|
|
238
|
+
res.end("Forbidden");
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const stat = await fs.promises.stat(filePath);
|
|
244
|
+
if (!stat.isFile()) {
|
|
245
|
+
// Try index.html for SPA fallback
|
|
246
|
+
const fallback = path.join(uiDir, "index.html");
|
|
247
|
+
if (fs.existsSync(fallback)) {
|
|
248
|
+
const content = await fs.promises.readFile(fallback);
|
|
249
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
250
|
+
res.end(content);
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const ext = path.extname(filePath);
|
|
257
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
258
|
+
const content = await fs.promises.readFile(filePath);
|
|
259
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
260
|
+
res.end(content);
|
|
261
|
+
return true;
|
|
262
|
+
} catch {
|
|
263
|
+
// File not found — try SPA fallback
|
|
264
|
+
const fallback = path.join(uiDir, "index.html");
|
|
265
|
+
try {
|
|
266
|
+
const content = await fs.promises.readFile(fallback);
|
|
267
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
268
|
+
res.end(content);
|
|
269
|
+
return true;
|
|
270
|
+
} catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// ─── Gateway RPC methods (for WebSocket access) ───
|
|
277
|
+
|
|
278
|
+
api.registerGatewayMethod("clawhq.files.read", async ({ params, respond }) => {
|
|
279
|
+
const filePath = typeof params?.path === "string" ? params.path : "";
|
|
280
|
+
const safe = sanitizePath(filePath);
|
|
281
|
+
if (!safe) { respond(false, undefined, { message: "Invalid path" }); return; }
|
|
282
|
+
|
|
283
|
+
const fullPath = path.join(workspaceDir, safe);
|
|
284
|
+
try {
|
|
285
|
+
const content = await fs.promises.readFile(fullPath, "utf-8");
|
|
286
|
+
respond(true, { path: safe, content });
|
|
287
|
+
} catch {
|
|
288
|
+
respond(false, undefined, { message: `File not found: ${safe}` });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
api.registerGatewayMethod("clawhq.files.write", async ({ params, respond }) => {
|
|
293
|
+
const filePath = typeof params?.path === "string" ? params.path : "";
|
|
294
|
+
const content = typeof params?.content === "string" ? params.content : null;
|
|
295
|
+
if (content === null) { respond(false, undefined, { message: "content required" }); return; }
|
|
296
|
+
|
|
297
|
+
const safe = sanitizePath(filePath);
|
|
298
|
+
if (!safe) { respond(false, undefined, { message: "Invalid path" }); return; }
|
|
299
|
+
|
|
300
|
+
const fullPath = path.join(workspaceDir, safe);
|
|
301
|
+
try {
|
|
302
|
+
await fs.promises.mkdir(path.dirname(fullPath), { recursive: true });
|
|
303
|
+
await fs.promises.writeFile(fullPath, content, "utf-8");
|
|
304
|
+
respond(true, { ok: true, path: safe });
|
|
305
|
+
} catch (err) {
|
|
306
|
+
respond(false, undefined, { message: `Write failed: ${err}` });
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
api.registerGatewayMethod("clawhq.files.list", async ({ params, respond }) => {
|
|
311
|
+
const dirPath = typeof params?.path === "string" ? params.path : "";
|
|
312
|
+
const safe = dirPath ? sanitizePath(dirPath) : "";
|
|
313
|
+
if (safe === null) { respond(false, undefined, { message: "Invalid path" }); return; }
|
|
314
|
+
|
|
315
|
+
const fullPath = path.join(workspaceDir, safe || "");
|
|
316
|
+
try {
|
|
317
|
+
const entries = await fs.promises.readdir(fullPath, { withFileTypes: true });
|
|
318
|
+
const files = entries.map(e => ({
|
|
319
|
+
name: e.name,
|
|
320
|
+
path: path.join(safe || "", e.name),
|
|
321
|
+
isDir: e.isDirectory(),
|
|
322
|
+
}));
|
|
323
|
+
respond(true, { path: safe || "/", files });
|
|
324
|
+
} catch {
|
|
325
|
+
respond(false, undefined, { message: `Directory not found: ${safe}` });
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Return only env var NAMES from secrets.env (never values)
|
|
330
|
+
api.registerGatewayMethod("clawhq.env.keys", async ({ respond }) => {
|
|
331
|
+
// Look for secrets.env in common locations relative to workspace
|
|
332
|
+
const candidates = [
|
|
333
|
+
path.join(workspaceDir, "..", "secrets.env"),
|
|
334
|
+
path.join(workspaceDir, "secrets.env"),
|
|
335
|
+
path.join(workspaceDir, ".secrets", "secrets.env"),
|
|
336
|
+
];
|
|
337
|
+
for (const filePath of candidates) {
|
|
338
|
+
try {
|
|
339
|
+
const content = await fs.promises.readFile(filePath, "utf-8");
|
|
340
|
+
const names = content.split("\n")
|
|
341
|
+
.filter(l => l.includes("=") && !l.startsWith("#") && l.trim())
|
|
342
|
+
.map(l => l.split("=")[0].trim())
|
|
343
|
+
.filter(Boolean);
|
|
344
|
+
respond(true, { names });
|
|
345
|
+
return;
|
|
346
|
+
} catch { continue; }
|
|
347
|
+
}
|
|
348
|
+
respond(true, { names: [] });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// ─── Skills: list all installed skills with parsed frontmatter ───
|
|
352
|
+
|
|
353
|
+
api.registerGatewayMethod("clawhq.skills.list", async ({ respond }) => {
|
|
354
|
+
const homeDir = process.env.HOME || "/root";
|
|
355
|
+
|
|
356
|
+
// Skill directories in precedence order (lowest → highest)
|
|
357
|
+
const skillDirs: { source: string; dir: string }[] = [];
|
|
358
|
+
|
|
359
|
+
// 1. Bundled skills (find openclaw package)
|
|
360
|
+
const bundledCandidates = [
|
|
361
|
+
path.join(homeDir, ".npm-global/lib/node_modules/openclaw/skills"),
|
|
362
|
+
path.join("/usr/local/lib/node_modules/openclaw/skills"),
|
|
363
|
+
path.join("/usr/lib/node_modules/openclaw/skills"),
|
|
364
|
+
];
|
|
365
|
+
for (const d of bundledCandidates) {
|
|
366
|
+
if (fs.existsSync(d)) { skillDirs.push({ source: "bundled", dir: d }); break; }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 2. Managed/local skills
|
|
370
|
+
const managedDir = path.join(homeDir, ".openclaw/skills");
|
|
371
|
+
if (fs.existsSync(managedDir)) skillDirs.push({ source: "managed", dir: managedDir });
|
|
372
|
+
|
|
373
|
+
// 3. Workspace skills
|
|
374
|
+
const workspaceSkillsDir = path.join(workspaceDir, "skills");
|
|
375
|
+
if (fs.existsSync(workspaceSkillsDir)) skillDirs.push({ source: "workspace", dir: workspaceSkillsDir });
|
|
376
|
+
|
|
377
|
+
// 4. Extra dirs from config
|
|
378
|
+
const extraDirs = config?.skills?.load?.extraDirs;
|
|
379
|
+
if (Array.isArray(extraDirs)) {
|
|
380
|
+
for (const d of extraDirs) {
|
|
381
|
+
if (typeof d === "string" && fs.existsSync(d)) {
|
|
382
|
+
skillDirs.push({ source: "extra", dir: d });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Collect skills (higher precedence overwrites lower)
|
|
388
|
+
const skillMap = new Map<string, any>();
|
|
389
|
+
|
|
390
|
+
for (const { source, dir } of skillDirs) {
|
|
391
|
+
try {
|
|
392
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
393
|
+
for (const entry of entries) {
|
|
394
|
+
if (!entry.isDirectory()) continue;
|
|
395
|
+
const skillMdPath = path.join(dir, entry.name, "SKILL.md");
|
|
396
|
+
try {
|
|
397
|
+
const content = await fs.promises.readFile(skillMdPath, "utf-8");
|
|
398
|
+
const parsed = parseSkillFrontmatter(content);
|
|
399
|
+
const name = parsed.name || entry.name;
|
|
400
|
+
const configEntry = config?.skills?.entries?.[entry.name] || config?.skills?.entries?.[name];
|
|
401
|
+
const enabled = configEntry?.enabled !== false;
|
|
402
|
+
|
|
403
|
+
const reqs = parsed.metadata?.openclaw?.requires || null;
|
|
404
|
+
const always = parsed.metadata?.openclaw?.always === true;
|
|
405
|
+
const osFilter = parsed.metadata?.openclaw?.os;
|
|
406
|
+
const missing: string[] = [];
|
|
407
|
+
|
|
408
|
+
// Check eligibility
|
|
409
|
+
if (!always && reqs) {
|
|
410
|
+
if (reqs.bins) {
|
|
411
|
+
for (const bin of reqs.bins) {
|
|
412
|
+
if (!binExistsOnPath(bin)) missing.push(`bin: ${bin}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (reqs.anyBins && reqs.anyBins.length > 0) {
|
|
416
|
+
if (!reqs.anyBins.some((b: string) => binExistsOnPath(b))) {
|
|
417
|
+
missing.push(`any bin: ${reqs.anyBins.join("|")}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (reqs.env) {
|
|
421
|
+
for (const envVar of reqs.env) {
|
|
422
|
+
const inEnv = !!process.env[envVar];
|
|
423
|
+
const inConfig = !!config?.skills?.entries?.[entry.name]?.env?.[envVar]
|
|
424
|
+
|| !!config?.skills?.entries?.[entry.name]?.apiKey;
|
|
425
|
+
if (!inEnv && !inConfig) missing.push(`env: ${envVar}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (reqs.config) {
|
|
429
|
+
for (const cfgPath of reqs.config) {
|
|
430
|
+
const val = cfgPath.split(".").reduce((o: any, k: string) => o?.[k], config);
|
|
431
|
+
if (!val) missing.push(`config: ${cfgPath}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (osFilter && Array.isArray(osFilter) && !osFilter.includes(process.platform)) {
|
|
436
|
+
missing.push(`os: ${osFilter.join("|")} (current: ${process.platform})`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const active = enabled && missing.length === 0;
|
|
440
|
+
|
|
441
|
+
skillMap.set(entry.name, {
|
|
442
|
+
key: entry.name,
|
|
443
|
+
name,
|
|
444
|
+
description: parsed.description || "",
|
|
445
|
+
source,
|
|
446
|
+
location: skillMdPath,
|
|
447
|
+
enabled,
|
|
448
|
+
active,
|
|
449
|
+
missingRequirements: missing.length > 0 ? missing : null,
|
|
450
|
+
homepage: parsed.metadata?.openclaw?.homepage || parsed.homepage || null,
|
|
451
|
+
emoji: parsed.metadata?.openclaw?.emoji || null,
|
|
452
|
+
requires: parsed.metadata?.openclaw?.requires || null,
|
|
453
|
+
primaryEnv: parsed.metadata?.openclaw?.primaryEnv || null,
|
|
454
|
+
userInvocable: parsed["user-invocable"] !== "false" && parsed["user-invocable"] !== false,
|
|
455
|
+
});
|
|
456
|
+
} catch { /* no SKILL.md */ }
|
|
457
|
+
}
|
|
458
|
+
} catch { /* dir unreadable */ }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
respond(true, { skills: Array.from(skillMap.values()) });
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// ─── Skills: read SKILL.md content ───
|
|
465
|
+
|
|
466
|
+
api.registerGatewayMethod("clawhq.skills.read", async ({ params, respond }) => {
|
|
467
|
+
const location = typeof params?.location === "string" ? params.location : "";
|
|
468
|
+
if (!location || !location.endsWith("SKILL.md")) {
|
|
469
|
+
respond(false, undefined, { message: "Invalid skill location" }); return;
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const content = await fs.promises.readFile(location, "utf-8");
|
|
473
|
+
respond(true, { content });
|
|
474
|
+
} catch {
|
|
475
|
+
respond(false, undefined, { message: `Cannot read: ${location}` });
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
log.info("ClawHQ: registered HTTP routes and gateway RPC methods");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ─── SKILL.md frontmatter parser ───
|
|
483
|
+
|
|
484
|
+
function parseSkillFrontmatter(content: string): Record<string, any> {
|
|
485
|
+
const result: Record<string, any> = {};
|
|
486
|
+
// Match YAML frontmatter between --- delimiters
|
|
487
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
488
|
+
if (!match) return result;
|
|
489
|
+
|
|
490
|
+
const fm = match[1];
|
|
491
|
+
// Simple line-by-line YAML parser (handles single-line values + inline JSON for metadata)
|
|
492
|
+
const lines = fm.split("\n");
|
|
493
|
+
let i = 0;
|
|
494
|
+
while (i < lines.length) {
|
|
495
|
+
const line = lines[i];
|
|
496
|
+
const kv = line.match(/^(\w[\w-]*):\s*(.*)/);
|
|
497
|
+
if (kv) {
|
|
498
|
+
const key = kv[1];
|
|
499
|
+
let value: any = kv[2].trim();
|
|
500
|
+
|
|
501
|
+
// Check for multi-line JSON (metadata field)
|
|
502
|
+
if (value === "" || value === "{") {
|
|
503
|
+
// Collect continuation lines
|
|
504
|
+
let jsonStr = value;
|
|
505
|
+
i++;
|
|
506
|
+
while (i < lines.length && !lines[i].match(/^(\w[\w-]*):\s/)) {
|
|
507
|
+
jsonStr += "\n" + lines[i];
|
|
508
|
+
i++;
|
|
509
|
+
}
|
|
510
|
+
jsonStr = jsonStr.trim();
|
|
511
|
+
if (jsonStr.startsWith("{")) {
|
|
512
|
+
try { value = JSON.parse(jsonStr); } catch { value = jsonStr; }
|
|
513
|
+
} else {
|
|
514
|
+
value = jsonStr || null;
|
|
515
|
+
}
|
|
516
|
+
continue; // already advanced i
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Strip quotes
|
|
520
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
521
|
+
value = value.slice(1, -1);
|
|
522
|
+
}
|
|
523
|
+
// Try inline JSON
|
|
524
|
+
if (value.startsWith("{")) {
|
|
525
|
+
try { value = JSON.parse(value); } catch { /* keep as string */ }
|
|
526
|
+
}
|
|
527
|
+
result[key] = value;
|
|
528
|
+
}
|
|
529
|
+
i++;
|
|
530
|
+
}
|
|
531
|
+
return result;
|
|
532
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "clawhq",
|
|
3
|
+
"name": "ClawHQ",
|
|
4
|
+
"description": "Owner-centric agent dashboard — activity feed, planning queue, cost tracking, cron management.",
|
|
5
|
+
"configSchema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"basePath": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "URL base path for the dashboard (default: /clawhq)"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clawhq",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Owner-centric agent dashboard for OpenClaw — activity feed, planning queue, cost tracking, cron management, skills browser.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/blbst123/clawhq.git"
|
|
9
|
+
},
|
|
10
|
+
"openclaw": {
|
|
11
|
+
"extensions": ["./index.ts"]
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.ts",
|
|
15
|
+
"openclaw.plugin.json",
|
|
16
|
+
"ui/**"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"dev": "next dev",
|
|
20
|
+
"build": "next build && rm -rf ui && cp -r out ui",
|
|
21
|
+
"start": "next start",
|
|
22
|
+
"lint": "eslint",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"class-variance-authority": "^0.7.1",
|
|
27
|
+
"clsx": "^2.1.1",
|
|
28
|
+
"lucide-react": "^0.563.0",
|
|
29
|
+
"next": "16.1.6",
|
|
30
|
+
"radix-ui": "^1.4.3",
|
|
31
|
+
"react": "19.2.3",
|
|
32
|
+
"react-dom": "19.2.3",
|
|
33
|
+
"react-markdown": "^10.1.0",
|
|
34
|
+
"remark-gfm": "^4.0.1",
|
|
35
|
+
"tailwind-merge": "^3.4.0",
|
|
36
|
+
"tw-animate-css": "^1.4.0",
|
|
37
|
+
"ws": "^8.19.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@tailwindcss/postcss": "^4",
|
|
41
|
+
"@types/node": "^20",
|
|
42
|
+
"@types/react": "^19",
|
|
43
|
+
"@types/react-dom": "^19",
|
|
44
|
+
"eslint": "^9",
|
|
45
|
+
"eslint-config-next": "16.1.6",
|
|
46
|
+
"tailwindcss": "^4",
|
|
47
|
+
"typescript": "^5"
|
|
48
|
+
}
|
|
49
|
+
}
|