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.
Files changed (135) hide show
  1. package/README.md +57 -0
  2. package/index.ts +532 -0
  3. package/openclaw.plugin.json +15 -0
  4. package/package.json +49 -0
  5. package/ui/404/index.html +1 -0
  6. package/ui/404.html +1 -0
  7. package/ui/__next.__PAGE__.txt +9 -0
  8. package/ui/__next._full.txt +23 -0
  9. package/ui/__next._head.txt +6 -0
  10. package/ui/__next._index.txt +8 -0
  11. package/ui/__next._tree.txt +4 -0
  12. package/ui/_next/static/DWx70nlvHGBzpFTu3Sxz5/_buildManifest.js +11 -0
  13. package/ui/_next/static/DWx70nlvHGBzpFTu3Sxz5/_clientMiddlewareManifest.json +1 -0
  14. package/ui/_next/static/DWx70nlvHGBzpFTu3Sxz5/_ssgManifest.js +1 -0
  15. package/ui/_next/static/chunks/05d833979760004b.js +1 -0
  16. package/ui/_next/static/chunks/0bd6498bda341889.js +1 -0
  17. package/ui/_next/static/chunks/0ce1bd3a49c9d2ee.js +1 -0
  18. package/ui/_next/static/chunks/0d1bcf8db95909b4.js +5 -0
  19. package/ui/_next/static/chunks/0e8dd92e1773b206.js +2 -0
  20. package/ui/_next/static/chunks/1d68ba38700c14af.js +1 -0
  21. package/ui/_next/static/chunks/1df4b55f9e090d79.js +1 -0
  22. package/ui/_next/static/chunks/25affa88b4c38de5.js +1 -0
  23. package/ui/_next/static/chunks/390d2f0ada7fcb51.js +1 -0
  24. package/ui/_next/static/chunks/66776cedfcec7fc8.js +1 -0
  25. package/ui/_next/static/chunks/6750ad6806bf564e.js +1 -0
  26. package/ui/_next/static/chunks/724cc24d423d0c1d.js +1 -0
  27. package/ui/_next/static/chunks/73acbb00f03d1647.js +6 -0
  28. package/ui/_next/static/chunks/74f28e1af7ed7ed3.js +1 -0
  29. package/ui/_next/static/chunks/7de9141b1af425c3.js +1 -0
  30. package/ui/_next/static/chunks/9006a9dbed1389eb.js +1 -0
  31. package/ui/_next/static/chunks/921e8895e15d1eea.css +3 -0
  32. package/ui/_next/static/chunks/a12c273af4edcf24.js +1 -0
  33. package/ui/_next/static/chunks/a2af742ffa61520a.js +6 -0
  34. package/ui/_next/static/chunks/a6dad97d9634a72d.js +1 -0
  35. package/ui/_next/static/chunks/a6dad97d9634a72d.js.map +1 -0
  36. package/ui/_next/static/chunks/e7b7178a9129f84d.js +1 -0
  37. package/ui/_next/static/chunks/f24f5995274b1b19.js +1 -0
  38. package/ui/_next/static/chunks/f9a7bd29e3728867.js +1 -0
  39. package/ui/_next/static/chunks/ff1a16fafef87110.js +1 -0
  40. package/ui/_next/static/chunks/turbopack-9730d705aa1aefde.js +4 -0
  41. package/ui/_next/static/media/4fa387ec64143e14-s.c1fdd6c2.woff2 +0 -0
  42. package/ui/_next/static/media/7178b3e590c64307-s.b97b3418.woff2 +0 -0
  43. package/ui/_next/static/media/797e433ab948586e-s.p.dbea232f.woff2 +0 -0
  44. package/ui/_next/static/media/8a480f0b521d4e75-s.8e0177b5.woff2 +0 -0
  45. package/ui/_next/static/media/bbc41e54d2fcbd21-s.799d8ef8.woff2 +0 -0
  46. package/ui/_next/static/media/caa3a2e1cccd8315-s.p.853070df.woff2 +0 -0
  47. package/ui/_next/static/media/favicon.0b3bf435.ico +0 -0
  48. package/ui/_not-found/__next._full.txt +17 -0
  49. package/ui/_not-found/__next._head.txt +6 -0
  50. package/ui/_not-found/__next._index.txt +8 -0
  51. package/ui/_not-found/__next._not-found.__PAGE__.txt +5 -0
  52. package/ui/_not-found/__next._not-found.txt +4 -0
  53. package/ui/_not-found/__next._tree.txt +2 -0
  54. package/ui/_not-found/index.html +1 -0
  55. package/ui/_not-found/index.txt +17 -0
  56. package/ui/access/__next._full.txt +23 -0
  57. package/ui/access/__next._head.txt +6 -0
  58. package/ui/access/__next._index.txt +8 -0
  59. package/ui/access/__next._tree.txt +4 -0
  60. package/ui/access/__next.access.__PAGE__.txt +9 -0
  61. package/ui/access/__next.access.txt +4 -0
  62. package/ui/access/index.html +1 -0
  63. package/ui/access/index.txt +23 -0
  64. package/ui/activity/__next._full.txt +23 -0
  65. package/ui/activity/__next._head.txt +6 -0
  66. package/ui/activity/__next._index.txt +8 -0
  67. package/ui/activity/__next._tree.txt +4 -0
  68. package/ui/activity/__next.activity.__PAGE__.txt +9 -0
  69. package/ui/activity/__next.activity.txt +4 -0
  70. package/ui/activity/index.html +1 -0
  71. package/ui/activity/index.txt +23 -0
  72. package/ui/calendar/__next._full.txt +23 -0
  73. package/ui/calendar/__next._head.txt +6 -0
  74. package/ui/calendar/__next._index.txt +8 -0
  75. package/ui/calendar/__next._tree.txt +4 -0
  76. package/ui/calendar/__next.calendar.__PAGE__.txt +9 -0
  77. package/ui/calendar/__next.calendar.txt +4 -0
  78. package/ui/calendar/index.html +1 -0
  79. package/ui/calendar/index.txt +23 -0
  80. package/ui/costs/__next._full.txt +23 -0
  81. package/ui/costs/__next._head.txt +6 -0
  82. package/ui/costs/__next._index.txt +8 -0
  83. package/ui/costs/__next._tree.txt +4 -0
  84. package/ui/costs/__next.costs.__PAGE__.txt +9 -0
  85. package/ui/costs/__next.costs.txt +4 -0
  86. package/ui/costs/index.html +1 -0
  87. package/ui/costs/index.txt +23 -0
  88. package/ui/cron/__next._full.txt +23 -0
  89. package/ui/cron/__next._head.txt +6 -0
  90. package/ui/cron/__next._index.txt +8 -0
  91. package/ui/cron/__next._tree.txt +4 -0
  92. package/ui/cron/__next.cron.__PAGE__.txt +9 -0
  93. package/ui/cron/__next.cron.txt +4 -0
  94. package/ui/cron/index.html +1 -0
  95. package/ui/cron/index.txt +23 -0
  96. package/ui/favicon.ico +0 -0
  97. package/ui/file.svg +1 -0
  98. package/ui/globe.svg +1 -0
  99. package/ui/index.html +1 -0
  100. package/ui/index.txt +23 -0
  101. package/ui/memory/__next._full.txt +23 -0
  102. package/ui/memory/__next._head.txt +6 -0
  103. package/ui/memory/__next._index.txt +8 -0
  104. package/ui/memory/__next._tree.txt +4 -0
  105. package/ui/memory/__next.memory.__PAGE__.txt +9 -0
  106. package/ui/memory/__next.memory.txt +4 -0
  107. package/ui/memory/index.html +1 -0
  108. package/ui/memory/index.txt +23 -0
  109. package/ui/next.svg +1 -0
  110. package/ui/planning/__next._full.txt +23 -0
  111. package/ui/planning/__next._head.txt +6 -0
  112. package/ui/planning/__next._index.txt +8 -0
  113. package/ui/planning/__next._tree.txt +4 -0
  114. package/ui/planning/__next.planning.__PAGE__.txt +9 -0
  115. package/ui/planning/__next.planning.txt +4 -0
  116. package/ui/planning/index.html +1 -0
  117. package/ui/planning/index.txt +23 -0
  118. package/ui/settings/__next._full.txt +23 -0
  119. package/ui/settings/__next._head.txt +6 -0
  120. package/ui/settings/__next._index.txt +8 -0
  121. package/ui/settings/__next._tree.txt +4 -0
  122. package/ui/settings/__next.settings.__PAGE__.txt +9 -0
  123. package/ui/settings/__next.settings.txt +4 -0
  124. package/ui/settings/index.html +1 -0
  125. package/ui/settings/index.txt +23 -0
  126. package/ui/skills/__next._full.txt +23 -0
  127. package/ui/skills/__next._head.txt +6 -0
  128. package/ui/skills/__next._index.txt +8 -0
  129. package/ui/skills/__next._tree.txt +4 -0
  130. package/ui/skills/__next.skills.__PAGE__.txt +9 -0
  131. package/ui/skills/__next.skills.txt +4 -0
  132. package/ui/skills/index.html +1 -0
  133. package/ui/skills/index.txt +23 -0
  134. package/ui/vercel.svg +1 -0
  135. 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
+ }