@castlekit/castle 0.0.1 → 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 +38 -1
- package/bin/castle.js +94 -0
- package/install.sh +722 -0
- package/next.config.ts +7 -0
- package/package.json +54 -5
- package/postcss.config.mjs +7 -0
- package/src/app/api/avatars/[id]/route.ts +75 -0
- package/src/app/api/openclaw/agents/route.ts +107 -0
- package/src/app/api/openclaw/config/route.ts +94 -0
- package/src/app/api/openclaw/events/route.ts +96 -0
- package/src/app/api/openclaw/logs/route.ts +59 -0
- package/src/app/api/openclaw/ping/route.ts +68 -0
- package/src/app/api/openclaw/restart/route.ts +65 -0
- package/src/app/api/openclaw/sessions/route.ts +62 -0
- package/src/app/globals.css +286 -0
- package/src/app/icon.png +0 -0
- package/src/app/layout.tsx +42 -0
- package/src/app/page.tsx +269 -0
- package/src/app/ui-kit/page.tsx +684 -0
- package/src/cli/onboarding.ts +576 -0
- package/src/components/dashboard/agent-status.tsx +107 -0
- package/src/components/dashboard/glass-card.tsx +28 -0
- package/src/components/dashboard/goal-widget.tsx +174 -0
- package/src/components/dashboard/greeting-widget.tsx +78 -0
- package/src/components/dashboard/index.ts +7 -0
- package/src/components/dashboard/stat-widget.tsx +61 -0
- package/src/components/dashboard/stock-widget.tsx +164 -0
- package/src/components/dashboard/weather-widget.tsx +68 -0
- package/src/components/icons/castle-icon.tsx +21 -0
- package/src/components/kanban/index.ts +3 -0
- package/src/components/kanban/kanban-board.tsx +391 -0
- package/src/components/kanban/kanban-card.tsx +137 -0
- package/src/components/kanban/kanban-column.tsx +98 -0
- package/src/components/layout/index.ts +4 -0
- package/src/components/layout/page-header.tsx +20 -0
- package/src/components/layout/sidebar.tsx +128 -0
- package/src/components/layout/theme-toggle.tsx +59 -0
- package/src/components/layout/user-menu.tsx +72 -0
- package/src/components/ui/alert.tsx +72 -0
- package/src/components/ui/avatar.tsx +87 -0
- package/src/components/ui/badge.tsx +39 -0
- package/src/components/ui/button.tsx +43 -0
- package/src/components/ui/card.tsx +107 -0
- package/src/components/ui/checkbox.tsx +56 -0
- package/src/components/ui/clock.tsx +171 -0
- package/src/components/ui/dialog.tsx +105 -0
- package/src/components/ui/index.ts +34 -0
- package/src/components/ui/input.tsx +112 -0
- package/src/components/ui/option-card.tsx +151 -0
- package/src/components/ui/progress.tsx +103 -0
- package/src/components/ui/radio.tsx +109 -0
- package/src/components/ui/select.tsx +46 -0
- package/src/components/ui/slider.tsx +62 -0
- package/src/components/ui/tabs.tsx +132 -0
- package/src/components/ui/toggle-group.tsx +85 -0
- package/src/components/ui/toggle.tsx +78 -0
- package/src/components/ui/tooltip.tsx +145 -0
- package/src/components/ui/uptime.tsx +106 -0
- package/src/lib/config.ts +195 -0
- package/src/lib/gateway-connection.ts +391 -0
- package/src/lib/hooks/use-openclaw.ts +163 -0
- package/src/lib/utils.ts +6 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
import WebSocket from "ws";
|
|
6
|
+
import { readFileSync } from "fs";
|
|
7
|
+
import { resolve as resolvePath, dirname } from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import {
|
|
10
|
+
isOpenClawInstalled,
|
|
11
|
+
readOpenClawToken,
|
|
12
|
+
readOpenClawPort,
|
|
13
|
+
ensureCastleDir,
|
|
14
|
+
writeConfig,
|
|
15
|
+
type CastleConfig,
|
|
16
|
+
} from "../lib/config.js";
|
|
17
|
+
|
|
18
|
+
// Read version from package.json at the project root
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const PROJECT_ROOT = resolvePath(__dirname, "..", "..");
|
|
21
|
+
let PKG_VERSION = "0.0.0";
|
|
22
|
+
try {
|
|
23
|
+
PKG_VERSION = JSON.parse(readFileSync(resolvePath(PROJECT_ROOT, "package.json"), "utf-8")).version;
|
|
24
|
+
} catch {
|
|
25
|
+
// Fallback if package.json is missing or invalid
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Castle blue helpers using standard ANSI colors (universal terminal support)
|
|
29
|
+
const BLUE = (s: string) => `\x1b[94m${s}\x1b[0m`; // bright blue
|
|
30
|
+
const BLUE_LIGHT = (s: string) => `\x1b[96m${s}\x1b[0m`; // bright cyan (lighter blue)
|
|
31
|
+
const BLUE_BOLD = (s: string) => `\x1b[1m\x1b[94m${s}\x1b[0m`; // bold bright blue
|
|
32
|
+
const BLUE_DIM = (s: string) => `\x1b[34m${s}\x1b[0m`; // standard blue (muted)
|
|
33
|
+
|
|
34
|
+
// Patch picocolors so @clack/prompts UI chrome (bars, dots, highlights) uses Castle blue
|
|
35
|
+
// @clack/prompts imports picocolors as an object reference, so overriding methods here
|
|
36
|
+
// changes the colors of all internal rendering (│ bars, ◆ dots, highlights, etc.)
|
|
37
|
+
const _pc = pc as unknown as Record<string, unknown>;
|
|
38
|
+
_pc.gray = BLUE_DIM;
|
|
39
|
+
_pc.green = BLUE;
|
|
40
|
+
_pc.greenBright = BLUE;
|
|
41
|
+
_pc.cyan = BLUE;
|
|
42
|
+
_pc.cyanBright = BLUE;
|
|
43
|
+
_pc.blue = BLUE;
|
|
44
|
+
_pc.blueBright = BLUE;
|
|
45
|
+
_pc.magenta = BLUE;
|
|
46
|
+
_pc.magentaBright = BLUE;
|
|
47
|
+
_pc.yellow = BLUE_DIM;
|
|
48
|
+
_pc.yellowBright = BLUE_DIM;
|
|
49
|
+
// red stays red for errors — no override
|
|
50
|
+
|
|
51
|
+
interface DiscoveredAgent {
|
|
52
|
+
id: string;
|
|
53
|
+
name: string;
|
|
54
|
+
description: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Connect to Gateway and discover agents via agents.list.
|
|
59
|
+
* Returns the list of agents or an empty array on failure.
|
|
60
|
+
*/
|
|
61
|
+
async function discoverAgents(port: number, token: string | null): Promise<DiscoveredAgent[]> {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const timeout = setTimeout(() => {
|
|
64
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
65
|
+
resolve([]);
|
|
66
|
+
}, 8000);
|
|
67
|
+
|
|
68
|
+
let ws: WebSocket;
|
|
69
|
+
try {
|
|
70
|
+
ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
71
|
+
} catch {
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
resolve([]);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
ws.on("error", () => {
|
|
78
|
+
clearTimeout(timeout);
|
|
79
|
+
resolve([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
ws.on("open", () => {
|
|
83
|
+
// Send connect handshake
|
|
84
|
+
const connectId = randomUUID();
|
|
85
|
+
const connectFrame = {
|
|
86
|
+
type: "req",
|
|
87
|
+
id: connectId,
|
|
88
|
+
method: "connect",
|
|
89
|
+
params: {
|
|
90
|
+
minProtocol: 3,
|
|
91
|
+
maxProtocol: 3,
|
|
92
|
+
client: {
|
|
93
|
+
id: "gateway-client",
|
|
94
|
+
displayName: "Castle CLI",
|
|
95
|
+
version: PKG_VERSION,
|
|
96
|
+
platform: process.platform,
|
|
97
|
+
mode: "backend",
|
|
98
|
+
},
|
|
99
|
+
auth: token ? { token } : undefined,
|
|
100
|
+
role: "operator",
|
|
101
|
+
scopes: ["operator.admin"],
|
|
102
|
+
caps: [],
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
ws.send(JSON.stringify(connectFrame));
|
|
106
|
+
|
|
107
|
+
// Wait for connect response, then request agents
|
|
108
|
+
ws.on("message", (data) => {
|
|
109
|
+
try {
|
|
110
|
+
const msg = JSON.parse(data.toString());
|
|
111
|
+
|
|
112
|
+
// Connect response
|
|
113
|
+
if (msg.type === "res" && msg.id === connectId) {
|
|
114
|
+
if (!msg.ok) {
|
|
115
|
+
clearTimeout(timeout);
|
|
116
|
+
ws.close();
|
|
117
|
+
resolve([]);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Connected -- now request agents
|
|
121
|
+
const agentsId = randomUUID();
|
|
122
|
+
const agentsFrame = {
|
|
123
|
+
type: "req",
|
|
124
|
+
id: agentsId,
|
|
125
|
+
method: "agents.list",
|
|
126
|
+
params: {},
|
|
127
|
+
};
|
|
128
|
+
ws.send(JSON.stringify(agentsFrame));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Agents response (any successful res that isn't the connect response)
|
|
132
|
+
if (msg.type === "res" && msg.id !== connectId && msg.ok) {
|
|
133
|
+
clearTimeout(timeout);
|
|
134
|
+
ws.close();
|
|
135
|
+
const payload = msg.payload || {};
|
|
136
|
+
const agentsList = Array.isArray(payload.agents) ? payload.agents : [];
|
|
137
|
+
const agents = agentsList.map((a: { id: string; name?: string; identity?: { name?: string; theme?: string } }) => ({
|
|
138
|
+
id: a.id,
|
|
139
|
+
name: a.identity?.name || a.name || a.id,
|
|
140
|
+
description: a.identity?.theme || null,
|
|
141
|
+
}));
|
|
142
|
+
resolve(agents);
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// ignore parse errors
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function runOnboarding(): Promise<void> {
|
|
153
|
+
|
|
154
|
+
p.intro(BLUE_BOLD("Castle Setup"));
|
|
155
|
+
|
|
156
|
+
p.note(
|
|
157
|
+
[
|
|
158
|
+
"Castle is your multi-agent workspace — a local-first",
|
|
159
|
+
"interface for managing and interacting with your",
|
|
160
|
+
"OpenClaw AI agents.",
|
|
161
|
+
"",
|
|
162
|
+
`${pc.dim("Learn more:")} ${BLUE_LIGHT("https://castlekit.com")}`,
|
|
163
|
+
].join("\n"),
|
|
164
|
+
"Welcome"
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const ready = await p.confirm({
|
|
168
|
+
message: "Ready to set up Castle?",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (p.isCancel(ready) || !ready) {
|
|
172
|
+
p.cancel("No worries — run castle setup when you're ready.");
|
|
173
|
+
process.exit(0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Step 1: Check for OpenClaw
|
|
177
|
+
const openclawSpinner = p.spinner();
|
|
178
|
+
openclawSpinner.start("Checking for OpenClaw...");
|
|
179
|
+
|
|
180
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
181
|
+
|
|
182
|
+
if (!isOpenClawInstalled()) {
|
|
183
|
+
openclawSpinner.stop(pc.dim("OpenClaw not found"));
|
|
184
|
+
|
|
185
|
+
p.note(
|
|
186
|
+
`Castle requires OpenClaw to run your AI agents.\n${BLUE_LIGHT("https://openclaw.ai")}`,
|
|
187
|
+
BLUE_BOLD("OpenClaw Required")
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const installChoice = await p.confirm({
|
|
191
|
+
message: "Would you like us to install OpenClaw with default settings?",
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (p.isCancel(installChoice)) {
|
|
195
|
+
p.cancel("Setup cancelled.");
|
|
196
|
+
process.exit(0);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (installChoice) {
|
|
200
|
+
const installSpinner = p.spinner();
|
|
201
|
+
installSpinner.start("Installing OpenClaw...");
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
execSync(
|
|
205
|
+
'curl -fsSL --proto "=https" --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --no-onboard --no-prompt',
|
|
206
|
+
{ stdio: "pipe", timeout: 120000 }
|
|
207
|
+
);
|
|
208
|
+
installSpinner.stop(BLUE("✔ OpenClaw installed"));
|
|
209
|
+
} catch (error) {
|
|
210
|
+
installSpinner.stop(pc.red("OpenClaw installation failed"));
|
|
211
|
+
p.note(
|
|
212
|
+
`Install OpenClaw manually:\n${BLUE_LIGHT(
|
|
213
|
+
"curl -fsSL https://openclaw.ai/install.sh | bash"
|
|
214
|
+
)}\n\nThen run: ${BLUE_LIGHT("castle setup")}`,
|
|
215
|
+
BLUE_BOLD("Manual Install")
|
|
216
|
+
);
|
|
217
|
+
p.outro("Come back when OpenClaw is installed!");
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
p.note(
|
|
222
|
+
`Install OpenClaw:\n${BLUE_LIGHT(
|
|
223
|
+
"curl -fsSL https://openclaw.ai/install.sh | bash"
|
|
224
|
+
)}\n\nThen come back and run:\n${BLUE_LIGHT("castle setup")}`,
|
|
225
|
+
BLUE_BOLD("Install OpenClaw First")
|
|
226
|
+
);
|
|
227
|
+
p.outro("See you soon!");
|
|
228
|
+
process.exit(0);
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
// Auto-detect token and agents in one go
|
|
232
|
+
const detectedPort = readOpenClawPort() || 18789;
|
|
233
|
+
const token = readOpenClawToken();
|
|
234
|
+
const agents = await discoverAgents(detectedPort, token);
|
|
235
|
+
|
|
236
|
+
openclawSpinner.stop(`\x1b[92m✔\x1b[0m OpenClaw detected`);
|
|
237
|
+
|
|
238
|
+
if (agents.length > 0 && token) {
|
|
239
|
+
p.log.message(
|
|
240
|
+
[
|
|
241
|
+
`${pc.dim("—")} ${pc.dim(`Gateway running on port ${detectedPort}`)}`,
|
|
242
|
+
`${pc.dim("—")} ${pc.dim("Auth token found")}`,
|
|
243
|
+
`${pc.dim("—")} ${pc.dim(`${agents.length} agent${agents.length !== 1 ? "s" : ""} discovered: ${agents.map((a) => a.name).join(", ")}`)}`,
|
|
244
|
+
].join("\n")
|
|
245
|
+
);
|
|
246
|
+
} else if (token) {
|
|
247
|
+
p.log.message(
|
|
248
|
+
[
|
|
249
|
+
`${pc.dim("—")} ${pc.dim("Auth token found")}`,
|
|
250
|
+
`${pc.dim("—")} ${pc.dim("Could not reach Gateway — agents will be discovered when it's running")}`,
|
|
251
|
+
].join("\n")
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Step 2: Auto-detect port and token, only ask if not found
|
|
257
|
+
let port = readOpenClawPort() || 18789;
|
|
258
|
+
let token = readOpenClawToken();
|
|
259
|
+
|
|
260
|
+
if (!readOpenClawPort()) {
|
|
261
|
+
const gatewayPort = await p.text({
|
|
262
|
+
message: "OpenClaw Gateway port",
|
|
263
|
+
initialValue: "18789",
|
|
264
|
+
validate(value: string | undefined) {
|
|
265
|
+
const num = parseInt(value || "0", 10);
|
|
266
|
+
if (isNaN(num) || num < 1 || num > 65535) {
|
|
267
|
+
return "Please enter a valid port number (1-65535)";
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (p.isCancel(gatewayPort)) {
|
|
273
|
+
p.cancel("Setup cancelled.");
|
|
274
|
+
process.exit(0);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
port = parseInt(gatewayPort as string, 10);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!token) {
|
|
281
|
+
const tokenInput = await p.text({
|
|
282
|
+
message: "Enter your OpenClaw Gateway token (or press Enter to skip)",
|
|
283
|
+
placeholder: "Leave empty if no auth is configured",
|
|
284
|
+
defaultValue: "",
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (p.isCancel(tokenInput)) {
|
|
288
|
+
p.cancel("Setup cancelled.");
|
|
289
|
+
process.exit(0);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
token = (tokenInput as string) || null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Step 4: Agent Discovery
|
|
296
|
+
const agents = await discoverAgents(port, token);
|
|
297
|
+
|
|
298
|
+
let primaryAgent: string;
|
|
299
|
+
|
|
300
|
+
if (agents.length > 0) {
|
|
301
|
+
|
|
302
|
+
const selectedAgent = await p.select({
|
|
303
|
+
message: "Choose your primary agent",
|
|
304
|
+
options: agents.map((a) => ({
|
|
305
|
+
value: a.id,
|
|
306
|
+
label: `${a.name} ${pc.dim(`<${a.id}>`)}`,
|
|
307
|
+
hint: a.description || undefined,
|
|
308
|
+
})),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
if (p.isCancel(selectedAgent)) {
|
|
312
|
+
p.cancel("Setup cancelled.");
|
|
313
|
+
process.exit(0);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
primaryAgent = selectedAgent as string;
|
|
317
|
+
} else {
|
|
318
|
+
const setPrimary = await p.text({
|
|
319
|
+
message: "Enter the name of your primary agent",
|
|
320
|
+
initialValue: "assistant",
|
|
321
|
+
validate(value: string | undefined) {
|
|
322
|
+
if (!value?.trim()) return "Agent name is required";
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
if (p.isCancel(setPrimary)) {
|
|
327
|
+
p.cancel("Setup cancelled.");
|
|
328
|
+
process.exit(0);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
primaryAgent = setPrimary as string;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Step 5: Create Castle config
|
|
335
|
+
ensureCastleDir();
|
|
336
|
+
|
|
337
|
+
const config: CastleConfig = {
|
|
338
|
+
openclaw: {
|
|
339
|
+
gateway_port: port,
|
|
340
|
+
gateway_token: token || undefined,
|
|
341
|
+
primary_agent: primaryAgent,
|
|
342
|
+
},
|
|
343
|
+
server: {
|
|
344
|
+
port: 3333,
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
writeConfig(config);
|
|
349
|
+
|
|
350
|
+
// Step 6: Build and start server as a persistent service
|
|
351
|
+
const serverSpinner = p.spinner();
|
|
352
|
+
serverSpinner.start("Building Castle...");
|
|
353
|
+
|
|
354
|
+
const { spawn, execSync: execSyncChild } = await import("child_process");
|
|
355
|
+
const { join } = await import("path");
|
|
356
|
+
const { writeFileSync: writeFile, mkdirSync: mkDir, readFileSync: readF } = await import("fs");
|
|
357
|
+
const { homedir: home } = await import("os");
|
|
358
|
+
|
|
359
|
+
const castleDir = join(home(), ".castle");
|
|
360
|
+
const logsDir = join(castleDir, "logs");
|
|
361
|
+
mkDir(logsDir, { recursive: true });
|
|
362
|
+
|
|
363
|
+
// Build for production
|
|
364
|
+
try {
|
|
365
|
+
execSyncChild("npm run build", {
|
|
366
|
+
cwd: PROJECT_ROOT,
|
|
367
|
+
stdio: "ignore",
|
|
368
|
+
timeout: 120000,
|
|
369
|
+
});
|
|
370
|
+
} catch {
|
|
371
|
+
serverSpinner.stop(pc.red("Build failed"));
|
|
372
|
+
p.outro(pc.dim(`Try running ${BLUE("npm run build")} manually in the castle directory.`));
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
serverSpinner.message("Starting Castle...");
|
|
377
|
+
|
|
378
|
+
// Find node and next paths for the service
|
|
379
|
+
const nodePath = process.execPath;
|
|
380
|
+
|
|
381
|
+
// Locate next binary reliably (works for both local and global installs)
|
|
382
|
+
let nextBin: string;
|
|
383
|
+
try {
|
|
384
|
+
// npm bin gives the local node_modules/.bin directory
|
|
385
|
+
const binDir = execSyncChild("npm bin", { cwd: PROJECT_ROOT, encoding: "utf-8" }).trim();
|
|
386
|
+
nextBin = join(binDir, "next");
|
|
387
|
+
} catch {
|
|
388
|
+
// Fallback: try local node_modules directly
|
|
389
|
+
nextBin = join(PROJECT_ROOT, "node_modules", ".bin", "next");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Castle port from config or default
|
|
393
|
+
const castlePort = String(config.server?.port || 3333);
|
|
394
|
+
|
|
395
|
+
// Write PID file helper
|
|
396
|
+
const pidFile = join(castleDir, "server.pid");
|
|
397
|
+
|
|
398
|
+
// Kill any existing Castle server and wait for it to release the port
|
|
399
|
+
try {
|
|
400
|
+
const existingPid = parseInt(readF(pidFile, "utf-8").trim(), 10);
|
|
401
|
+
if (Number.isInteger(existingPid) && existingPid > 0) {
|
|
402
|
+
process.kill(existingPid);
|
|
403
|
+
// Wait up to 3s for old process to die
|
|
404
|
+
for (let i = 0; i < 30; i++) {
|
|
405
|
+
try {
|
|
406
|
+
process.kill(existingPid, 0); // Test if alive
|
|
407
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
408
|
+
} catch {
|
|
409
|
+
break; // Process is gone
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
} catch {
|
|
414
|
+
// No existing server or already dead
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Start production server
|
|
418
|
+
const server = spawn(nodePath, [nextBin, "start", "-p", castlePort], {
|
|
419
|
+
cwd: PROJECT_ROOT,
|
|
420
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
421
|
+
detached: true,
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// Write PID file so we can manage the server later
|
|
425
|
+
if (server.pid != null) {
|
|
426
|
+
writeFile(pidFile, String(server.pid));
|
|
427
|
+
}
|
|
428
|
+
server.unref();
|
|
429
|
+
|
|
430
|
+
// Install as a persistent service (auto-start on login)
|
|
431
|
+
if (process.platform === "darwin") {
|
|
432
|
+
// macOS: LaunchAgent
|
|
433
|
+
// We unload first to avoid conflicts, then load. KeepAlive is set to
|
|
434
|
+
// SuccessfulExit=false so launchd only restarts on crashes, not when
|
|
435
|
+
// we intentionally stop it.
|
|
436
|
+
const plistDir = join(home(), "Library", "LaunchAgents");
|
|
437
|
+
mkDir(plistDir, { recursive: true });
|
|
438
|
+
const plistPath = join(plistDir, "com.castlekit.castle.plist");
|
|
439
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
440
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
441
|
+
<plist version="1.0">
|
|
442
|
+
<dict>
|
|
443
|
+
<key>Label</key>
|
|
444
|
+
<string>com.castlekit.castle</string>
|
|
445
|
+
<key>ProgramArguments</key>
|
|
446
|
+
<array>
|
|
447
|
+
<string>${nodePath}</string>
|
|
448
|
+
<string>${nextBin}</string>
|
|
449
|
+
<string>start</string>
|
|
450
|
+
<string>-p</string>
|
|
451
|
+
<string>${castlePort}</string>
|
|
452
|
+
</array>
|
|
453
|
+
<key>WorkingDirectory</key>
|
|
454
|
+
<string>${PROJECT_ROOT}</string>
|
|
455
|
+
<key>RunAtLoad</key>
|
|
456
|
+
<true/>
|
|
457
|
+
<key>KeepAlive</key>
|
|
458
|
+
<dict>
|
|
459
|
+
<key>SuccessfulExit</key>
|
|
460
|
+
<false/>
|
|
461
|
+
</dict>
|
|
462
|
+
<key>StandardOutPath</key>
|
|
463
|
+
<string>${logsDir}/server.log</string>
|
|
464
|
+
<key>StandardErrorPath</key>
|
|
465
|
+
<string>${logsDir}/server.err</string>
|
|
466
|
+
<key>EnvironmentVariables</key>
|
|
467
|
+
<dict>
|
|
468
|
+
<key>NODE_ENV</key>
|
|
469
|
+
<string>production</string>
|
|
470
|
+
<key>PATH</key>
|
|
471
|
+
<string>${process.env.PATH}</string>
|
|
472
|
+
</dict>
|
|
473
|
+
</dict>
|
|
474
|
+
</plist>`;
|
|
475
|
+
// Stop any running instance first, then kill our manually spawned one
|
|
476
|
+
// so launchd takes over as the sole process manager
|
|
477
|
+
try {
|
|
478
|
+
execSyncChild(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: "ignore" });
|
|
479
|
+
} catch { /* ignore */ }
|
|
480
|
+
writeFile(plistPath, plist);
|
|
481
|
+
try {
|
|
482
|
+
// Kill the spawn()ed server — launchd will now be the owner
|
|
483
|
+
if (server.pid) {
|
|
484
|
+
try { process.kill(server.pid); } catch { /* already dead */ }
|
|
485
|
+
}
|
|
486
|
+
execSyncChild(`launchctl load "${plistPath}"`, { stdio: "ignore" });
|
|
487
|
+
} catch {
|
|
488
|
+
// Non-fatal: server is already running via spawn
|
|
489
|
+
}
|
|
490
|
+
} else if (process.platform === "linux") {
|
|
491
|
+
// Linux: systemd user service
|
|
492
|
+
const systemdDir = join(home(), ".config", "systemd", "user");
|
|
493
|
+
mkDir(systemdDir, { recursive: true });
|
|
494
|
+
const servicePath = join(systemdDir, "castle.service");
|
|
495
|
+
const service = `[Unit]
|
|
496
|
+
Description=Castle - The multi-agent workspace
|
|
497
|
+
After=network.target
|
|
498
|
+
|
|
499
|
+
[Service]
|
|
500
|
+
ExecStart=${nodePath} ${nextBin} start -p ${castlePort}
|
|
501
|
+
WorkingDirectory=${PROJECT_ROOT}
|
|
502
|
+
Restart=on-failure
|
|
503
|
+
RestartSec=5
|
|
504
|
+
Environment=NODE_ENV=production
|
|
505
|
+
Environment=PATH=${process.env.PATH}
|
|
506
|
+
|
|
507
|
+
[Install]
|
|
508
|
+
WantedBy=default.target
|
|
509
|
+
`;
|
|
510
|
+
writeFile(servicePath, service);
|
|
511
|
+
try {
|
|
512
|
+
execSyncChild("systemctl --user daemon-reload && systemctl --user enable --now castle.service", { stdio: "ignore" });
|
|
513
|
+
} catch {
|
|
514
|
+
// Non-fatal: server is already running via spawn
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Wait for server to be ready
|
|
519
|
+
const maxWait = 30000;
|
|
520
|
+
const startTime = Date.now();
|
|
521
|
+
let serverReady = false;
|
|
522
|
+
|
|
523
|
+
while (Date.now() - startTime < maxWait) {
|
|
524
|
+
try {
|
|
525
|
+
const res = await fetch(`http://localhost:${castlePort}`);
|
|
526
|
+
if (res.ok || res.status === 404) {
|
|
527
|
+
serverReady = true;
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
} catch {
|
|
531
|
+
// Server not ready yet
|
|
532
|
+
}
|
|
533
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (!serverReady) {
|
|
537
|
+
serverSpinner.stop(pc.red("Server took too long to start"));
|
|
538
|
+
p.outro(pc.dim(`Check logs at ${BLUE("~/.castle/logs/")}`));
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
serverSpinner.stop(`\x1b[92m✔\x1b[0m Castle is running`);
|
|
543
|
+
|
|
544
|
+
// Find the display name for the selected primary agent
|
|
545
|
+
const primaryDisplay = agents.find((a) => a.id === primaryAgent)?.name || primaryAgent;
|
|
546
|
+
|
|
547
|
+
p.note(
|
|
548
|
+
[
|
|
549
|
+
"",
|
|
550
|
+
` ${BLUE_BOLD("🏰 Welcome to Castle!")}`,
|
|
551
|
+
"",
|
|
552
|
+
` ${pc.dim("Data directory")} ${BLUE_LIGHT("~/.castle/")}`,
|
|
553
|
+
` ${pc.dim("Config")} ${BLUE_LIGHT("~/.castle/castle.json")}`,
|
|
554
|
+
` ${pc.dim("Primary agent")} ${BLUE_BOLD(primaryDisplay)}`,
|
|
555
|
+
"",
|
|
556
|
+
` ${BLUE_BOLD("➜")} \x1b[1m\x1b[4m\x1b[94mhttp://localhost:${castlePort}\x1b[0m`,
|
|
557
|
+
"",
|
|
558
|
+
` ${pc.dim("Your agents are ready. Let's go!")} 🚀`,
|
|
559
|
+
"",
|
|
560
|
+
].join("\n"),
|
|
561
|
+
BLUE_BOLD("✨ Setup Complete")
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
const openBrowser = await p.confirm({
|
|
565
|
+
message: "Want to open Castle in your browser?",
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
if (p.isCancel(openBrowser) || !openBrowser) {
|
|
569
|
+
p.outro(pc.dim(`Run ${BLUE("castle")} anytime to launch Castle.`));
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
p.outro(pc.dim(`Opening ${BLUE(`http://localhost:${castlePort}`)}...`));
|
|
574
|
+
const open = (await import("open")).default;
|
|
575
|
+
await open(`http://localhost:${castlePort}`);
|
|
576
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
type AgentStatusType = "online" | "busy" | "idle" | "offline";
|
|
6
|
+
|
|
7
|
+
interface DisplayAgent {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
role: string;
|
|
11
|
+
status: AgentStatusType;
|
|
12
|
+
avatar?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AgentStatusWidgetProps {
|
|
16
|
+
variant?: "solid" | "glass";
|
|
17
|
+
agents?: DisplayAgent[];
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const statusColors: Record<AgentStatusType, string> = {
|
|
22
|
+
online: "bg-success",
|
|
23
|
+
busy: "bg-warning",
|
|
24
|
+
idle: "bg-info",
|
|
25
|
+
offline: "bg-foreground-muted",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const statusLabels: Record<AgentStatusType, string> = {
|
|
29
|
+
online: "Active",
|
|
30
|
+
busy: "Working",
|
|
31
|
+
idle: "Idle",
|
|
32
|
+
offline: "Offline",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const defaultAgents: DisplayAgent[] = [
|
|
36
|
+
{ id: "1", name: "Atlas", role: "Executive Assistant", status: "online" },
|
|
37
|
+
{ id: "2", name: "Mason", role: "Full-Stack Developer", status: "busy" },
|
|
38
|
+
{ id: "3", name: "Sage", role: "Research Analyst", status: "online" },
|
|
39
|
+
{ id: "4", name: "Max", role: "Data Engineer", status: "idle" },
|
|
40
|
+
{ id: "5", name: "Merlin", role: "Creative Director", status: "offline" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function AgentStatusWidget({
|
|
44
|
+
variant = "solid",
|
|
45
|
+
agents = defaultAgents,
|
|
46
|
+
className,
|
|
47
|
+
}: AgentStatusWidgetProps) {
|
|
48
|
+
const onlineCount = agents.filter(
|
|
49
|
+
(a) => a.status === "online" || a.status === "busy"
|
|
50
|
+
).length;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className={cn(
|
|
54
|
+
"rounded-[var(--radius-lg)] p-6",
|
|
55
|
+
variant === "glass" ? "glass" : "bg-surface border border-border",
|
|
56
|
+
className
|
|
57
|
+
)}>
|
|
58
|
+
<div className="flex items-center justify-between mb-4">
|
|
59
|
+
<h3 className="text-sm font-medium text-foreground">Agents</h3>
|
|
60
|
+
<span className="text-xs text-foreground-secondary">
|
|
61
|
+
{onlineCount} active
|
|
62
|
+
</span>
|
|
63
|
+
</div>
|
|
64
|
+
<div className="space-y-3">
|
|
65
|
+
{agents.map((agent) => (
|
|
66
|
+
<div key={agent.id} className="flex items-center gap-3">
|
|
67
|
+
<div className="relative">
|
|
68
|
+
<div className="h-8 w-8 rounded-[var(--radius-full)] bg-surface flex items-center justify-center text-sm font-medium text-foreground-secondary border border-border">
|
|
69
|
+
{agent.name[0]}
|
|
70
|
+
</div>
|
|
71
|
+
<span
|
|
72
|
+
className={cn(
|
|
73
|
+
"absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-[var(--radius-full)] ring-2 ring-background",
|
|
74
|
+
statusColors[agent.status]
|
|
75
|
+
)}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
<div className="flex-1 min-w-0">
|
|
79
|
+
<p className="text-sm font-medium text-foreground truncate">
|
|
80
|
+
{agent.name}
|
|
81
|
+
</p>
|
|
82
|
+
<p className="text-xs text-foreground-muted truncate">
|
|
83
|
+
{agent.role}
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
<span
|
|
87
|
+
className={cn(
|
|
88
|
+
"text-xs px-2 py-0.5 rounded-[var(--radius-full)]",
|
|
89
|
+
{
|
|
90
|
+
"bg-success/10 text-success": agent.status === "online",
|
|
91
|
+
"bg-warning/10 text-warning": agent.status === "busy",
|
|
92
|
+
"bg-info/10 text-info": agent.status === "idle",
|
|
93
|
+
"bg-foreground-muted/10 text-foreground-muted":
|
|
94
|
+
agent.status === "offline",
|
|
95
|
+
}
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
{statusLabels[agent.status]}
|
|
99
|
+
</span>
|
|
100
|
+
</div>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export { AgentStatusWidget };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
export interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
intensity?: "subtle" | "medium" | "strong";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const GlassCard = forwardRef<HTMLDivElement, GlassCardProps>(
|
|
9
|
+
({ className, intensity = "subtle", children, ...props }, ref) => {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
className={cn(
|
|
13
|
+
"glass rounded-[var(--radius-lg)] p-6 transition-all",
|
|
14
|
+
className
|
|
15
|
+
)}
|
|
16
|
+
data-intensity={intensity}
|
|
17
|
+
ref={ref}
|
|
18
|
+
{...props}
|
|
19
|
+
>
|
|
20
|
+
{children}
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
GlassCard.displayName = "GlassCard";
|
|
27
|
+
|
|
28
|
+
export { GlassCard };
|