@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.
Files changed (63) hide show
  1. package/README.md +38 -1
  2. package/bin/castle.js +94 -0
  3. package/install.sh +722 -0
  4. package/next.config.ts +7 -0
  5. package/package.json +54 -5
  6. package/postcss.config.mjs +7 -0
  7. package/src/app/api/avatars/[id]/route.ts +75 -0
  8. package/src/app/api/openclaw/agents/route.ts +107 -0
  9. package/src/app/api/openclaw/config/route.ts +94 -0
  10. package/src/app/api/openclaw/events/route.ts +96 -0
  11. package/src/app/api/openclaw/logs/route.ts +59 -0
  12. package/src/app/api/openclaw/ping/route.ts +68 -0
  13. package/src/app/api/openclaw/restart/route.ts +65 -0
  14. package/src/app/api/openclaw/sessions/route.ts +62 -0
  15. package/src/app/globals.css +286 -0
  16. package/src/app/icon.png +0 -0
  17. package/src/app/layout.tsx +42 -0
  18. package/src/app/page.tsx +269 -0
  19. package/src/app/ui-kit/page.tsx +684 -0
  20. package/src/cli/onboarding.ts +576 -0
  21. package/src/components/dashboard/agent-status.tsx +107 -0
  22. package/src/components/dashboard/glass-card.tsx +28 -0
  23. package/src/components/dashboard/goal-widget.tsx +174 -0
  24. package/src/components/dashboard/greeting-widget.tsx +78 -0
  25. package/src/components/dashboard/index.ts +7 -0
  26. package/src/components/dashboard/stat-widget.tsx +61 -0
  27. package/src/components/dashboard/stock-widget.tsx +164 -0
  28. package/src/components/dashboard/weather-widget.tsx +68 -0
  29. package/src/components/icons/castle-icon.tsx +21 -0
  30. package/src/components/kanban/index.ts +3 -0
  31. package/src/components/kanban/kanban-board.tsx +391 -0
  32. package/src/components/kanban/kanban-card.tsx +137 -0
  33. package/src/components/kanban/kanban-column.tsx +98 -0
  34. package/src/components/layout/index.ts +4 -0
  35. package/src/components/layout/page-header.tsx +20 -0
  36. package/src/components/layout/sidebar.tsx +128 -0
  37. package/src/components/layout/theme-toggle.tsx +59 -0
  38. package/src/components/layout/user-menu.tsx +72 -0
  39. package/src/components/ui/alert.tsx +72 -0
  40. package/src/components/ui/avatar.tsx +87 -0
  41. package/src/components/ui/badge.tsx +39 -0
  42. package/src/components/ui/button.tsx +43 -0
  43. package/src/components/ui/card.tsx +107 -0
  44. package/src/components/ui/checkbox.tsx +56 -0
  45. package/src/components/ui/clock.tsx +171 -0
  46. package/src/components/ui/dialog.tsx +105 -0
  47. package/src/components/ui/index.ts +34 -0
  48. package/src/components/ui/input.tsx +112 -0
  49. package/src/components/ui/option-card.tsx +151 -0
  50. package/src/components/ui/progress.tsx +103 -0
  51. package/src/components/ui/radio.tsx +109 -0
  52. package/src/components/ui/select.tsx +46 -0
  53. package/src/components/ui/slider.tsx +62 -0
  54. package/src/components/ui/tabs.tsx +132 -0
  55. package/src/components/ui/toggle-group.tsx +85 -0
  56. package/src/components/ui/toggle.tsx +78 -0
  57. package/src/components/ui/tooltip.tsx +145 -0
  58. package/src/components/ui/uptime.tsx +106 -0
  59. package/src/lib/config.ts +195 -0
  60. package/src/lib/gateway-connection.ts +391 -0
  61. package/src/lib/hooks/use-openclaw.ts +163 -0
  62. package/src/lib/utils.ts +6 -0
  63. package/tsconfig.json +34 -0
package/install.sh ADDED
@@ -0,0 +1,722 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # Castle Installer for macOS and Linux
5
+ # Usage: curl -fsSL --proto '=https' --tlsv1.2 https://castlekit.com/install.sh | bash
6
+
7
+ BOLD='\033[1m'
8
+ ACCENT='\033[38;2;59;130;246m'
9
+ ACCENT_DIM='\033[38;2;37;99;235m'
10
+ INFO='\033[38;2;96;165;250m'
11
+ SUCCESS='\033[38;2;34;197;94m'
12
+ WARN='\033[38;2;245;158;11m'
13
+ ERROR='\033[38;2;239;68;68m'
14
+ MUTED='\033[38;2;113;113;122m'
15
+ NC='\033[0m' # No Color
16
+
17
+ DEFAULT_TAGLINE="The multi-agent workspace."
18
+
19
+ ORIGINAL_PATH="${PATH:-}"
20
+
21
+ TMPFILES=()
22
+ cleanup_tmpfiles() {
23
+ local f
24
+ for f in "${TMPFILES[@]:-}"; do
25
+ rm -f "$f" 2>/dev/null || true
26
+ done
27
+ }
28
+ trap cleanup_tmpfiles EXIT
29
+
30
+ mktempfile() {
31
+ local f
32
+ f="$(mktemp)"
33
+ TMPFILES+=("$f")
34
+ echo "$f"
35
+ }
36
+
37
+ DOWNLOADER=""
38
+ detect_downloader() {
39
+ if command -v curl &> /dev/null; then
40
+ DOWNLOADER="curl"
41
+ return 0
42
+ fi
43
+ if command -v wget &> /dev/null; then
44
+ DOWNLOADER="wget"
45
+ return 0
46
+ fi
47
+ echo -e "${ERROR}Error: Missing downloader (curl or wget required)${NC}"
48
+ exit 1
49
+ }
50
+
51
+ download_file() {
52
+ local url="$1"
53
+ local output="$2"
54
+ if [[ -z "$DOWNLOADER" ]]; then
55
+ detect_downloader
56
+ fi
57
+ if [[ "$DOWNLOADER" == "curl" ]]; then
58
+ curl -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused -o "$output" "$url"
59
+ return
60
+ fi
61
+ wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=20 -O "$output" "$url"
62
+ }
63
+
64
+ run_remote_bash() {
65
+ local url="$1"
66
+ local tmp
67
+ tmp="$(mktempfile)"
68
+ download_file "$url" "$tmp"
69
+ /bin/bash "$tmp"
70
+ }
71
+
72
+ # ─── Taglines ────────────────────────────────────────────────────────────────
73
+
74
+ TAGLINES=()
75
+ TAGLINES+=("Your kingdom awaits, sire.")
76
+ TAGLINES+=("The throne room is ready.")
77
+ TAGLINES+=("A fortress for your AI agents.")
78
+ TAGLINES+=("All hail the command center.")
79
+ TAGLINES+=("Knights of the round terminal.")
80
+ TAGLINES+=("Raise the drawbridge, lower the latency.")
81
+ TAGLINES+=("By royal decree, your agents are assembled.")
82
+ TAGLINES+=("The court is now in session.")
83
+ TAGLINES+=("From castle walls to API calls.")
84
+ TAGLINES+=("Forged in code, ruled by you.")
85
+ TAGLINES+=("Every king needs a castle.")
86
+ TAGLINES+=("Where agents serve and dragons compile.")
87
+ TAGLINES+=("The siege of busywork ends here.")
88
+ TAGLINES+=("Hear ye, hear ye — your agents await.")
89
+ TAGLINES+=("A castle built on open source bedrock.")
90
+ TAGLINES+=("One does not simply walk in without a CLI.")
91
+ TAGLINES+=("The moat is deep but the docs are deeper.")
92
+ TAGLINES+=("Fear not the dark mode, for it is default.")
93
+ TAGLINES+=("In the land of AI, the castlekeeper wears a hoodie.")
94
+ TAGLINES+=("Your agents kneel before the terminal.")
95
+ TAGLINES+=("Excalibur was a sword. This is better.")
96
+ TAGLINES+=("npm install --save-the-kingdom.")
97
+ TAGLINES+=("The Round Table, but make it a dashboard.")
98
+ TAGLINES+=("Dragons? Handled. Bugs? Working on it.")
99
+ TAGLINES+=("A quest to automate the mundane.")
100
+
101
+ pick_tagline() {
102
+ local count=${#TAGLINES[@]}
103
+ if [[ "$count" -eq 0 ]]; then
104
+ echo "$DEFAULT_TAGLINE"
105
+ return
106
+ fi
107
+ if [[ -n "${CASTLE_TAGLINE_INDEX:-}" ]]; then
108
+ if [[ "${CASTLE_TAGLINE_INDEX}" =~ ^[0-9]+$ ]]; then
109
+ local idx=$((CASTLE_TAGLINE_INDEX % count))
110
+ echo "${TAGLINES[$idx]}"
111
+ return
112
+ fi
113
+ fi
114
+ local idx=$((RANDOM % count))
115
+ echo "${TAGLINES[$idx]}"
116
+ }
117
+
118
+ TAGLINE=$(pick_tagline)
119
+
120
+ # ─── Options ─────────────────────────────────────────────────────────────────
121
+
122
+ NO_ONBOARD=${CASTLE_NO_ONBOARD:-0}
123
+ NO_PROMPT=${CASTLE_NO_PROMPT:-0}
124
+ DRY_RUN=${CASTLE_DRY_RUN:-0}
125
+ CASTLE_VERSION=${CASTLE_VERSION:-latest}
126
+ VERBOSE="${CASTLE_VERBOSE:-0}"
127
+ NPM_LOGLEVEL="${CASTLE_NPM_LOGLEVEL:-error}"
128
+ NPM_SILENT_FLAG="--silent"
129
+ CASTLE_BIN=""
130
+ HELP=0
131
+
132
+ print_usage() {
133
+ cat <<EOF
134
+ Castle installer (macOS + Linux)
135
+
136
+ Usage:
137
+ curl -fsSL --proto '=https' --tlsv1.2 https://castlekit.com/install.sh | bash -s -- [options]
138
+
139
+ Options:
140
+ --version <version> npm version to install (default: latest)
141
+ --no-onboard Skip setup wizard after install
142
+ --no-prompt Disable prompts (for CI/automation)
143
+ --dry-run Print what would happen (no changes)
144
+ --verbose Print debug output
145
+ --help, -h Show this help
146
+
147
+ Environment variables:
148
+ CASTLE_VERSION=latest|<semver>
149
+ CASTLE_NO_ONBOARD=0|1
150
+ CASTLE_NO_PROMPT=1
151
+ CASTLE_DRY_RUN=1
152
+ CASTLE_VERBOSE=1
153
+ CASTLE_NPM_LOGLEVEL=error|warn|notice
154
+
155
+ Examples:
156
+ curl -fsSL --proto '=https' --tlsv1.2 https://castlekit.com/install.sh | bash
157
+ curl -fsSL --proto '=https' --tlsv1.2 https://castlekit.com/install.sh | bash -s -- --no-onboard
158
+ EOF
159
+ }
160
+
161
+ parse_args() {
162
+ while [[ $# -gt 0 ]]; do
163
+ case "$1" in
164
+ --no-onboard)
165
+ NO_ONBOARD=1
166
+ shift
167
+ ;;
168
+ --dry-run)
169
+ DRY_RUN=1
170
+ shift
171
+ ;;
172
+ --verbose)
173
+ VERBOSE=1
174
+ shift
175
+ ;;
176
+ --no-prompt)
177
+ NO_PROMPT=1
178
+ shift
179
+ ;;
180
+ --help|-h)
181
+ HELP=1
182
+ shift
183
+ ;;
184
+ --version)
185
+ if [[ -z "${2:-}" ]]; then
186
+ echo "Error: --version requires a value"
187
+ exit 1
188
+ fi
189
+ CASTLE_VERSION="$2"
190
+ shift 2
191
+ ;;
192
+ *)
193
+ shift
194
+ ;;
195
+ esac
196
+ done
197
+ }
198
+
199
+ configure_verbose() {
200
+ if [[ "$VERBOSE" != "1" ]]; then
201
+ return 0
202
+ fi
203
+ if [[ "$NPM_LOGLEVEL" == "error" ]]; then
204
+ NPM_LOGLEVEL="notice"
205
+ fi
206
+ NPM_SILENT_FLAG=""
207
+ set -x
208
+ }
209
+
210
+ is_promptable() {
211
+ if [[ "$NO_PROMPT" == "1" ]]; then
212
+ return 1
213
+ fi
214
+ if [[ -r /dev/tty && -w /dev/tty ]]; then
215
+ return 0
216
+ fi
217
+ return 1
218
+ }
219
+
220
+ # ─── System detection ────────────────────────────────────────────────────────
221
+
222
+ is_root() {
223
+ [[ "$(id -u)" -eq 0 ]]
224
+ }
225
+
226
+ maybe_sudo() {
227
+ if is_root; then
228
+ if [[ "${1:-}" == "-E" ]]; then
229
+ shift
230
+ fi
231
+ "$@"
232
+ else
233
+ sudo "$@"
234
+ fi
235
+ }
236
+
237
+ require_sudo() {
238
+ if [[ "$OS" != "linux" ]]; then
239
+ return 0
240
+ fi
241
+ if is_root; then
242
+ return 0
243
+ fi
244
+ if command -v sudo &> /dev/null; then
245
+ return 0
246
+ fi
247
+ echo -e "${ERROR}Error: sudo is required for system installs on Linux${NC}"
248
+ echo "Install sudo or re-run as root."
249
+ exit 1
250
+ }
251
+
252
+ # ─── Homebrew ────────────────────────────────────────────────────────────────
253
+
254
+ install_homebrew() {
255
+ if [[ "$OS" == "macos" ]]; then
256
+ if ! command -v brew &> /dev/null; then
257
+ echo -e "${WARN}→${NC} Installing Homebrew..."
258
+ run_remote_bash "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh"
259
+
260
+ if [[ -f "/opt/homebrew/bin/brew" ]]; then
261
+ eval "$(/opt/homebrew/bin/brew shellenv)"
262
+ elif [[ -f "/usr/local/bin/brew" ]]; then
263
+ eval "$(/usr/local/bin/brew shellenv)"
264
+ fi
265
+ echo -e "${SUCCESS}✓${NC} Homebrew installed"
266
+ else
267
+ echo -e "${SUCCESS}✓${NC} Homebrew already installed"
268
+ fi
269
+ fi
270
+ }
271
+
272
+ # ─── Node.js ─────────────────────────────────────────────────────────────────
273
+
274
+ check_node() {
275
+ if command -v node &> /dev/null; then
276
+ NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
277
+ if [[ "$NODE_VERSION" -ge 22 ]]; then
278
+ echo -e "${SUCCESS}✓${NC} Node.js v$(node -v | cut -d'v' -f2) found"
279
+ return 0
280
+ else
281
+ echo -e "${WARN}→${NC} Node.js $(node -v) found, but v22+ required"
282
+ return 1
283
+ fi
284
+ else
285
+ echo -e "${WARN}→${NC} Node.js not found"
286
+ return 1
287
+ fi
288
+ }
289
+
290
+ install_node() {
291
+ if [[ "$OS" == "macos" ]]; then
292
+ echo -e "${WARN}→${NC} Installing Node.js via Homebrew..."
293
+ brew install node@22
294
+ brew link node@22 --overwrite --force 2>/dev/null || true
295
+ echo -e "${SUCCESS}✓${NC} Node.js installed"
296
+ elif [[ "$OS" == "linux" ]]; then
297
+ echo -e "${WARN}→${NC} Installing Node.js via NodeSource..."
298
+ require_sudo
299
+ if command -v apt-get &> /dev/null; then
300
+ local tmp
301
+ tmp="$(mktempfile)"
302
+ download_file "https://deb.nodesource.com/setup_22.x" "$tmp"
303
+ maybe_sudo -E bash "$tmp"
304
+ maybe_sudo apt-get install -y nodejs
305
+ elif command -v dnf &> /dev/null; then
306
+ local tmp
307
+ tmp="$(mktempfile)"
308
+ download_file "https://rpm.nodesource.com/setup_22.x" "$tmp"
309
+ maybe_sudo bash "$tmp"
310
+ maybe_sudo dnf install -y nodejs
311
+ elif command -v yum &> /dev/null; then
312
+ local tmp
313
+ tmp="$(mktempfile)"
314
+ download_file "https://rpm.nodesource.com/setup_22.x" "$tmp"
315
+ maybe_sudo bash "$tmp"
316
+ maybe_sudo yum install -y nodejs
317
+ else
318
+ echo -e "${ERROR}Error: Could not detect package manager${NC}"
319
+ echo "Please install Node.js 22+ manually: https://nodejs.org"
320
+ exit 1
321
+ fi
322
+ echo -e "${SUCCESS}✓${NC} Node.js installed"
323
+ fi
324
+ }
325
+
326
+ # ─── Git ─────────────────────────────────────────────────────────────────────
327
+
328
+ check_git() {
329
+ if command -v git &> /dev/null; then
330
+ echo -e "${SUCCESS}✓${NC} Git already installed"
331
+ return 0
332
+ fi
333
+ echo -e "${WARN}→${NC} Git not found"
334
+ return 1
335
+ }
336
+
337
+ install_git() {
338
+ echo -e "${WARN}→${NC} Installing Git..."
339
+ if [[ "$OS" == "macos" ]]; then
340
+ brew install git
341
+ elif [[ "$OS" == "linux" ]]; then
342
+ require_sudo
343
+ if command -v apt-get &> /dev/null; then
344
+ maybe_sudo apt-get update -y
345
+ maybe_sudo apt-get install -y git
346
+ elif command -v dnf &> /dev/null; then
347
+ maybe_sudo dnf install -y git
348
+ elif command -v yum &> /dev/null; then
349
+ maybe_sudo yum install -y git
350
+ else
351
+ echo -e "${ERROR}Error: Could not detect package manager for Git${NC}"
352
+ exit 1
353
+ fi
354
+ fi
355
+ echo -e "${SUCCESS}✓${NC} Git installed"
356
+ }
357
+
358
+ # ─── npm permissions ─────────────────────────────────────────────────────────
359
+
360
+ fix_npm_permissions() {
361
+ if [[ "$OS" != "linux" ]]; then
362
+ return 0
363
+ fi
364
+
365
+ local npm_prefix
366
+ npm_prefix="$(npm config get prefix 2>/dev/null || true)"
367
+ if [[ -z "$npm_prefix" ]]; then
368
+ return 0
369
+ fi
370
+
371
+ if [[ -w "$npm_prefix" || -w "$npm_prefix/lib" ]]; then
372
+ return 0
373
+ fi
374
+
375
+ echo -e "${WARN}→${NC} Configuring npm for user-local installs..."
376
+ mkdir -p "$HOME/.npm-global"
377
+ npm config set prefix "$HOME/.npm-global"
378
+
379
+ # shellcheck disable=SC2016
380
+ local path_line='export PATH="$HOME/.npm-global/bin:$PATH"'
381
+ for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
382
+ if [[ -f "$rc" ]] && ! grep -q ".npm-global" "$rc"; then
383
+ echo "$path_line" >> "$rc"
384
+ fi
385
+ done
386
+
387
+ export PATH="$HOME/.npm-global/bin:$PATH"
388
+ echo -e "${SUCCESS}✓${NC} npm configured for user installs"
389
+ }
390
+
391
+ # ─── PATH helpers ────────────────────────────────────────────────────────────
392
+
393
+ npm_global_bin_dir() {
394
+ local prefix=""
395
+ prefix="$(npm prefix -g 2>/dev/null || true)"
396
+ if [[ -n "$prefix" ]]; then
397
+ if [[ "$prefix" == /* ]]; then
398
+ echo "${prefix%/}/bin"
399
+ return 0
400
+ fi
401
+ fi
402
+
403
+ prefix="$(npm config get prefix 2>/dev/null || true)"
404
+ if [[ -n "$prefix" && "$prefix" != "undefined" && "$prefix" != "null" ]]; then
405
+ if [[ "$prefix" == /* ]]; then
406
+ echo "${prefix%/}/bin"
407
+ return 0
408
+ fi
409
+ fi
410
+
411
+ echo ""
412
+ return 1
413
+ }
414
+
415
+ refresh_shell_command_cache() {
416
+ hash -r 2>/dev/null || true
417
+ }
418
+
419
+ path_has_dir() {
420
+ local path="$1"
421
+ local dir="${2%/}"
422
+ if [[ -z "$dir" ]]; then
423
+ return 1
424
+ fi
425
+ case ":${path}:" in
426
+ *":${dir}:"*) return 0 ;;
427
+ *) return 1 ;;
428
+ esac
429
+ }
430
+
431
+ warn_shell_path_missing_dir() {
432
+ local dir="${1%/}"
433
+ local label="$2"
434
+ if [[ -z "$dir" ]]; then
435
+ return 0
436
+ fi
437
+ if path_has_dir "$ORIGINAL_PATH" "$dir"; then
438
+ return 0
439
+ fi
440
+
441
+ echo ""
442
+ echo -e "${WARN}→${NC} PATH warning: missing ${label}: ${INFO}${dir}${NC}"
443
+ echo -e "This can make ${INFO}castle${NC} show as \"command not found\" in new terminals."
444
+ echo -e "Fix (zsh: ~/.zshrc, bash: ~/.bashrc):"
445
+ echo -e " export PATH=\"${dir}:\$PATH\""
446
+ }
447
+
448
+ ensure_npm_global_bin_on_path() {
449
+ local bin_dir=""
450
+ bin_dir="$(npm_global_bin_dir || true)"
451
+ if [[ -n "$bin_dir" ]]; then
452
+ export PATH="${bin_dir}:$PATH"
453
+ fi
454
+ }
455
+
456
+ maybe_nodenv_rehash() {
457
+ if command -v nodenv &> /dev/null; then
458
+ nodenv rehash >/dev/null 2>&1 || true
459
+ fi
460
+ }
461
+
462
+ source_node_version_manager() {
463
+ # Source nvm if available (curl|bash subshells don't have it loaded)
464
+ if [[ -z "${NVM_DIR:-}" ]]; then
465
+ if [[ -d "$HOME/.nvm" ]]; then
466
+ export NVM_DIR="$HOME/.nvm"
467
+ fi
468
+ fi
469
+ if [[ -n "${NVM_DIR:-}" && -s "${NVM_DIR}/nvm.sh" ]]; then
470
+ source "${NVM_DIR}/nvm.sh" 2>/dev/null || true
471
+ fi
472
+
473
+ # Source fnm if available
474
+ if command -v fnm &> /dev/null; then
475
+ eval "$(fnm env 2>/dev/null)" || true
476
+ fi
477
+ }
478
+
479
+ resolve_castle_bin() {
480
+ refresh_shell_command_cache
481
+ local resolved=""
482
+ resolved="$(type -P castle 2>/dev/null || true)"
483
+ if [[ -n "$resolved" && -x "$resolved" ]]; then
484
+ echo "$resolved"
485
+ return 0
486
+ fi
487
+
488
+ # Source nvm/fnm in case we're in a curl|bash subshell
489
+ source_node_version_manager
490
+ refresh_shell_command_cache
491
+ resolved="$(type -P castle 2>/dev/null || true)"
492
+ if [[ -n "$resolved" && -x "$resolved" ]]; then
493
+ echo "$resolved"
494
+ return 0
495
+ fi
496
+
497
+ ensure_npm_global_bin_on_path
498
+ refresh_shell_command_cache
499
+ resolved="$(type -P castle 2>/dev/null || true)"
500
+ if [[ -n "$resolved" && -x "$resolved" ]]; then
501
+ echo "$resolved"
502
+ return 0
503
+ fi
504
+
505
+ local npm_bin=""
506
+ npm_bin="$(npm_global_bin_dir || true)"
507
+ if [[ -n "$npm_bin" && -x "${npm_bin}/castle" ]]; then
508
+ echo "${npm_bin}/castle"
509
+ return 0
510
+ fi
511
+
512
+ # Brute force: check common global bin locations
513
+ local common_paths=(
514
+ "$HOME/.nvm/versions/node/$(node -v 2>/dev/null || echo v0)/bin/castle"
515
+ "$HOME/.npm-global/bin/castle"
516
+ "/usr/local/bin/castle"
517
+ "/opt/homebrew/bin/castle"
518
+ )
519
+ for p in "${common_paths[@]}"; do
520
+ if [[ -x "$p" ]]; then
521
+ echo "$p"
522
+ return 0
523
+ fi
524
+ done
525
+
526
+ maybe_nodenv_rehash
527
+ refresh_shell_command_cache
528
+ resolved="$(type -P castle 2>/dev/null || true)"
529
+ if [[ -n "$resolved" && -x "$resolved" ]]; then
530
+ echo "$resolved"
531
+ return 0
532
+ fi
533
+
534
+ echo ""
535
+ return 1
536
+ }
537
+
538
+ warn_castle_not_found() {
539
+ echo -e "${WARN}→${NC} Installed, but ${INFO}castle${NC} is not discoverable on PATH in this shell."
540
+ echo -e "Try: ${INFO}hash -r${NC} (bash) or ${INFO}rehash${NC} (zsh), then retry."
541
+ local npm_bin=""
542
+ npm_bin="$(npm_global_bin_dir 2>/dev/null || true)"
543
+ if [[ -n "$npm_bin" ]]; then
544
+ echo -e "npm bin dir: ${INFO}${npm_bin}${NC}"
545
+ echo -e "If needed: ${INFO}export PATH=\"${npm_bin}:\$PATH\"${NC}"
546
+ fi
547
+ }
548
+
549
+ # ─── Install Castle ──────────────────────────────────────────────────────────
550
+
551
+ install_castle() {
552
+ local install_spec="@castlekit/castle@${CASTLE_VERSION}"
553
+
554
+ local resolved_version=""
555
+ resolved_version="$(npm view "${install_spec}" version 2>/dev/null || true)"
556
+ if [[ -n "$resolved_version" ]]; then
557
+ echo -e "${WARN}→${NC} Installing Castle ${INFO}${resolved_version}${NC}..."
558
+ else
559
+ echo -e "${WARN}→${NC} Installing Castle (${INFO}${CASTLE_VERSION}${NC})..."
560
+ fi
561
+
562
+ if ! npm --loglevel "$NPM_LOGLEVEL" ${NPM_SILENT_FLAG:+$NPM_SILENT_FLAG} --no-fund --no-audit install -g "$install_spec"; then
563
+ echo -e "${ERROR}npm install failed${NC}"
564
+ echo -e "Try: ${INFO}npm install -g --force ${install_spec}${NC}"
565
+ exit 1
566
+ fi
567
+
568
+ echo -e "${SUCCESS}✓${NC} Castle installed"
569
+ }
570
+
571
+ # ─── Main ────────────────────────────────────────────────────────────────────
572
+
573
+ main() {
574
+ if [[ "$HELP" == "1" ]]; then
575
+ print_usage
576
+ return 0
577
+ fi
578
+
579
+ if [[ "$DRY_RUN" == "1" ]]; then
580
+ echo -e "${SUCCESS}✓${NC} Dry run"
581
+ echo -e "${SUCCESS}✓${NC} Version: ${CASTLE_VERSION}"
582
+ echo -e "${MUTED}Dry run complete (no changes made).${NC}"
583
+ return 0
584
+ fi
585
+
586
+ # Check for existing installation
587
+ local is_upgrade=false
588
+ if [[ -n "$(type -P castle 2>/dev/null || true)" ]]; then
589
+ echo -e "${WARN}→${NC} Existing Castle installation detected"
590
+ is_upgrade=true
591
+ fi
592
+
593
+ # Step 1: Homebrew (macOS only)
594
+ install_homebrew
595
+
596
+ # Step 2: Node.js
597
+ if ! check_node; then
598
+ install_node
599
+ fi
600
+
601
+ # Step 3: Git
602
+ if ! check_git; then
603
+ install_git
604
+ fi
605
+
606
+ # Step 4: npm permissions (Linux)
607
+ fix_npm_permissions
608
+
609
+ # Step 5: Install Castle
610
+ install_castle
611
+
612
+ CASTLE_BIN="$(resolve_castle_bin || true)"
613
+
614
+ echo ""
615
+ echo -e "${SUCCESS}${BOLD}🏰 Castle installed successfully!${NC}"
616
+
617
+ if [[ "$is_upgrade" == "true" ]]; then
618
+ local update_messages=(
619
+ "The castle walls have been reinforced, my liege."
620
+ "New fortifications in place. The kingdom grows stronger."
621
+ "The royal engineers have been busy. Upgrade complete."
622
+ "Fresh stonework, same castle. Miss me?"
623
+ "The drawbridge has been upgraded. Smoother entry guaranteed."
624
+ )
625
+ local update_message
626
+ update_message="${update_messages[RANDOM % ${#update_messages[@]}]}"
627
+ echo -e "${MUTED}${update_message}${NC}"
628
+ else
629
+ local completion_messages=(
630
+ "The castle has been erected. Long may it stand!"
631
+ "Your fortress is ready, sire. What are your orders?"
632
+ "The court is assembled. Your agents await."
633
+ "A fine castle indeed. Time to rule."
634
+ "Stone by stone, the kingdom begins."
635
+ )
636
+ local completion_message
637
+ completion_message="${completion_messages[RANDOM % ${#completion_messages[@]}]}"
638
+ echo -e "${MUTED}${completion_message}${NC}"
639
+ fi
640
+ echo ""
641
+
642
+ # Step 6: Run onboarding
643
+ if [[ "$NO_ONBOARD" == "1" ]]; then
644
+ echo -e "Skipping setup (requested). Run ${INFO}castle setup${NC} later."
645
+ else
646
+ if [[ -r /dev/tty && -w /dev/tty ]]; then
647
+ echo -e "Starting setup..."
648
+ echo ""
649
+ exec </dev/tty
650
+ if [[ -n "$CASTLE_BIN" ]]; then
651
+ exec "$CASTLE_BIN" setup
652
+ else
653
+ # Fallback: run via npx (always works, no PATH needed)
654
+ exec npx --yes @castlekit/castle setup
655
+ fi
656
+ else
657
+ echo -e "${WARN}→${NC} No TTY available; skipping setup."
658
+ echo -e "Run ${INFO}castle setup${NC} later."
659
+ fi
660
+ fi
661
+ }
662
+
663
+ # ─── Entry ───────────────────────────────────────────────────────────────────
664
+
665
+ # ASCII castle banner with blue-to-purple gradient (ANSI 256-color)
666
+ print_banner() {
667
+ local lines=(
668
+ ' |>>>'
669
+ ' |'
670
+ ' |>>> _ _|_ _ |>>>'
671
+ ' | |;| |;| |;| |'
672
+ ' _ _|_ _ \. . / _ _|_ _'
673
+ ' |;|_|;|_|;| \:. , / |;|_|;|_|;|'
674
+ ' \.. / ||; . | \. . /'
675
+ ' \. , / ||: . | \: . /'
676
+ ' ||: |_ _ ||_ . _ | _ _||: |'
677
+ ' ||: .|||_|;|_|;|_|;|_|;|_|;||:. |'
678
+ ' ||: ||. . . . ||: .|'
679
+ ' ||: . || . . . . , ||: | \,/'
680
+ ' ||: ||: , _______ . ||: , | /`\\'
681
+ ' ||: || . /+++++++\ . ||: |'
682
+ ' ||: ||. |+++++++| . ||: . |'
683
+ ' __ ||: . ||: , |+++++++|. . _||_ |'
684
+ ' ____--`~ '"'"'--~~__|. |+++++__|----~ ~`---, ___'
685
+ '-~--~ ~---__|,--~'"'"' ~~----_____-~'"'"' `~----~~'
686
+ )
687
+ # Blue-to-purple gradient using ANSI 256-color codes
688
+ local gradient=(27 27 33 33 63 63 99 99 135 135 141 141 177 177 177 176 176 176)
689
+ local i=0
690
+ echo ""
691
+ for line in "${lines[@]}"; do
692
+ local color=${gradient[$i]}
693
+ echo -e "\033[38;5;${color}m${line}\033[0m"
694
+ ((i++)) || true
695
+ done
696
+ echo ""
697
+ echo -e " ${ACCENT}${BOLD}Castle${NC} ${MUTED}— The multi-agent workspace${NC}"
698
+ echo -e " ${MUTED}${TAGLINE}${NC}"
699
+ echo ""
700
+ }
701
+
702
+ print_banner
703
+
704
+ # Detect OS
705
+ OS="unknown"
706
+ if [[ "$OSTYPE" == "darwin"* ]]; then
707
+ OS="macos"
708
+ elif [[ "$OSTYPE" == "linux-gnu"* ]] || [[ -n "${WSL_DISTRO_NAME:-}" ]]; then
709
+ OS="linux"
710
+ fi
711
+
712
+ if [[ "$OS" == "unknown" ]]; then
713
+ echo -e "${ERROR}Error: Unsupported operating system${NC}"
714
+ echo "This installer supports macOS and Linux (including WSL)."
715
+ exit 1
716
+ fi
717
+
718
+ echo -e "${SUCCESS}✓${NC} Detected: $OS"
719
+
720
+ parse_args "$@"
721
+ configure_verbose
722
+ main