fluxy-bot 0.1.0 → 0.1.2

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/bin/cli.js CHANGED
@@ -4,7 +4,6 @@ import { spawn, execSync } from 'child_process';
4
4
  import fs from 'fs';
5
5
  import path from 'path';
6
6
  import os from 'os';
7
- import readline from 'readline';
8
7
  import { fileURLToPath } from 'url';
9
8
 
10
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -14,8 +13,6 @@ const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
14
13
  const BIN_DIR = path.join(DATA_DIR, 'bin');
15
14
  const CF_PATH = path.join(BIN_DIR, 'cloudflared');
16
15
 
17
- const RELAY_API = 'https://api.fluxy.bot/api';
18
-
19
16
  const [, , command] = process.argv;
20
17
 
21
18
  // ── UI helpers ──
@@ -29,7 +26,6 @@ const c = {
29
26
  yellow: '\x1b[33m',
30
27
  red: '\x1b[31m',
31
28
  white: '\x1b[97m',
32
- bg: '\x1b[48;5;236m',
33
29
  };
34
30
 
35
31
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -101,7 +97,7 @@ class Stepper {
101
97
  function banner() {
102
98
  console.log(`
103
99
  ${c.cyan}${c.bold}╔═══════════════════════════════╗
104
- ║ FLUXY v0.1.0
100
+ ║ FLUXY v0.1.1
105
101
  ╚═══════════════════════════════╝${c.reset}
106
102
  ${c.dim}Self-hosted AI bot${c.reset}`);
107
103
  }
@@ -110,7 +106,7 @@ function finalMessage(tunnelUrl, relayUrl) {
110
106
  console.log(`
111
107
  ${c.dim}─────────────────────────────────${c.reset}
112
108
 
113
- ${c.bold}${c.white}Continue your setup at:${c.reset}
109
+ ${c.bold}${c.white}Open your dashboard to finish setup:${c.reset}
114
110
 
115
111
  ${c.cyan}${c.bold}${tunnelUrl}${c.reset}`);
116
112
 
@@ -127,18 +123,6 @@ function finalMessage(tunnelUrl, relayUrl) {
127
123
  `);
128
124
  }
129
125
 
130
- // ── Terminal input ──
131
-
132
- function ask(question) {
133
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
134
- return new Promise((resolve) => {
135
- rl.question(question, (answer) => {
136
- rl.close();
137
- resolve(answer.trim());
138
- });
139
- });
140
- }
141
-
142
126
  // ── Steps ──
143
127
 
144
128
  function createConfig() {
@@ -197,161 +181,51 @@ async function installCloudflared() {
197
181
  fs.chmodSync(CF_PATH, 0o755);
198
182
  }
199
183
 
200
- // ── Relay registration ──
201
-
202
- async function registerWithRelay() {
203
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
204
-
205
- // Skip if already registered
206
- if (config.relay?.token) return;
207
-
208
- console.log(`
209
- ${c.bold}${c.white}Choose your bot handle${c.reset}
210
- ${c.dim}This is your permanent URL to access your bot from anywhere.${c.reset}
211
- `);
212
-
213
- let username = '';
214
- let registered = false;
215
-
216
- while (!registered) {
217
- username = await ask(` ${c.cyan}Handle:${c.reset} `);
218
-
219
- if (!username) {
220
- console.log(` ${c.dim}Skipping relay registration. You can set this up later.${c.reset}\n`);
221
- return;
222
- }
223
-
224
- username = username.toLowerCase().replace(/[^a-z0-9-]/g, '');
225
-
226
- if (username.length < 3) {
227
- console.log(` ${c.yellow}Must be at least 3 characters.${c.reset}\n`);
228
- continue;
229
- }
230
-
231
- // Check availability
232
- try {
233
- const checkRes = await fetch(`${RELAY_API}/availability/${encodeURIComponent(username)}`);
234
- const check = await checkRes.json();
235
-
236
- if (!check.valid) {
237
- console.log(` ${c.yellow}${check.error}${c.reset}\n`);
238
- continue;
239
- }
240
-
241
- if (!check.available) {
242
- console.log(` ${c.yellow}That handle is taken. Try another.${c.reset}\n`);
243
- continue;
244
- }
245
- } catch {
246
- console.log(` ${c.yellow}Could not reach relay server. Skipping registration.${c.reset}\n`);
247
- return;
248
- }
249
-
250
- // Show options
251
- console.log(`
252
- ${c.bold}${c.white}Available handles:${c.reset}
253
- ${c.dim}1)${c.reset} ${c.cyan}${username}.fluxy.bot${c.reset} ${c.dim}(Premium — $5)${c.reset}
254
- ${c.dim}2)${c.reset} ${c.cyan}my.fluxy.bot/${username}${c.reset} ${c.green}Free${c.reset}
255
- ${c.dim}3)${c.reset} ${c.cyan}at.fluxy.bot/${username}${c.reset} ${c.green}Free${c.reset}
256
- ${c.dim}4)${c.reset} ${c.cyan}on.fluxy.bot/${username}${c.reset} ${c.green}Free${c.reset}
257
- `);
258
-
259
- const choice = await ask(` ${c.cyan}Pick (1-4):${c.reset} `);
260
- const tiers = ['premium', 'my', 'at', 'on'];
261
- const tierIndex = parseInt(choice, 10) - 1;
262
- const tier = tiers[tierIndex] ?? 'my'; // default to free
263
-
264
- if (tier === 'premium') {
265
- console.log(` ${c.yellow}Premium handles require payment (coming soon). Using free tier.${c.reset}\n`);
266
- }
267
-
268
- const finalTier = tier === 'premium' ? 'my' : tier;
269
-
270
- // Register
271
- try {
272
- const regRes = await fetch(`${RELAY_API}/register`, {
273
- method: 'POST',
274
- headers: { 'Content-Type': 'application/json' },
275
- body: JSON.stringify({ username, tier: finalTier }),
276
- });
277
-
278
- const reg = await regRes.json();
279
-
280
- if (!regRes.ok) {
281
- console.log(` ${c.yellow}${reg.error}${c.reset}\n`);
282
- continue;
283
- }
284
-
285
- // Save to config
286
- config.username = username;
287
- config.relay = {
288
- token: reg.token,
289
- tier: finalTier,
290
- url: reg.relayUrl,
291
- };
292
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
293
-
294
- console.log(` ${c.green}✔${c.reset} Registered! Your URL: ${c.cyan}${c.bold}${reg.relayUrl}${c.reset}\n`);
295
- registered = true;
296
- } catch {
297
- console.log(` ${c.yellow}Registration failed. Skipping — you can try again later.${c.reset}\n`);
298
- return;
299
- }
300
- }
301
- }
302
-
303
184
  // ── Boot server ──
304
185
 
305
186
  function bootServer() {
306
187
  return new Promise((resolve) => {
307
188
  const child = spawn(
308
- 'node',
189
+ process.execPath,
309
190
  ['--import', 'tsx/esm', path.join(ROOT, 'supervisor/index.ts')],
310
191
  { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } },
311
192
  );
312
193
 
313
194
  let tunnelUrl = null;
314
195
  let relayUrl = null;
315
-
316
- const fallbackLocal = () => {
317
- if (!tunnelUrl) {
318
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
319
- resolve({ child, tunnelUrl: `http://localhost:${config.port}`, relayUrl: config.relay?.url || null });
320
- }
196
+ let resolved = false;
197
+
198
+ const doResolve = () => {
199
+ if (resolved) return;
200
+ resolved = true;
201
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
202
+ resolve({
203
+ child,
204
+ tunnelUrl: tunnelUrl || `http://localhost:${config.port}`,
205
+ relayUrl: relayUrl || config.relay?.url || null,
206
+ });
321
207
  };
322
208
 
323
209
  const handleData = (data) => {
324
210
  const text = data.toString();
325
211
 
326
212
  const tunnelMatch = text.match(/__TUNNEL_URL__=(\S+)/);
327
- if (tunnelMatch) {
328
- tunnelUrl = tunnelMatch[1];
329
- }
213
+ if (tunnelMatch) tunnelUrl = tunnelMatch[1];
330
214
 
331
215
  const relayMatch = text.match(/__RELAY_URL__=(\S+)/);
332
- if (relayMatch) {
333
- relayUrl = relayMatch[1];
334
- }
335
-
336
- // Resolve once we have both tunnel URL and relay (or relay fails)
337
- if (tunnelUrl && !relayUrl) {
338
- // Give relay a moment to register, then resolve anyway
339
- setTimeout(() => {
340
- if (!relayUrl) {
341
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
342
- resolve({ child, tunnelUrl, relayUrl: config.relay?.url || null });
343
- }
344
- }, 3000);
345
- }
216
+ if (relayMatch) relayUrl = relayMatch[1];
346
217
 
347
218
  if (tunnelUrl && relayUrl) {
348
- resolve({ child, tunnelUrl, relayUrl });
219
+ doResolve();
349
220
  return;
350
221
  }
351
222
 
223
+ if (tunnelUrl && !relayUrl) {
224
+ setTimeout(doResolve, 3000);
225
+ }
226
+
352
227
  if (text.includes('__TUNNEL_FAILED__')) {
353
- fallbackLocal();
354
- return;
228
+ doResolve();
355
229
  }
356
230
  };
357
231
 
@@ -370,35 +244,26 @@ function bootServer() {
370
244
  async function init() {
371
245
  banner();
372
246
 
373
- const steps = [
374
- 'Creating config',
375
- 'Registering handle',
376
- 'Installing cloudflared',
377
- 'Starting server',
378
- 'Connecting tunnel',
379
- ];
380
-
381
- // Step 1: Config
382
247
  createConfig();
383
248
 
384
- // Step 2: Relay registration (interactive — before stepper)
385
- await registerWithRelay();
386
-
387
- // Now the non-interactive steps
388
- const buildSteps = [
249
+ const steps = [
250
+ 'Creating config',
389
251
  'Installing cloudflared',
390
252
  'Starting server',
391
253
  'Connecting tunnel',
392
254
  ];
393
255
 
394
- const stepper = new Stepper(buildSteps);
256
+ const stepper = new Stepper(steps);
395
257
  stepper.start();
396
258
 
397
- // Step 3: Cloudflared
259
+ // Config already created
260
+ stepper.advance();
261
+
262
+ // Cloudflared
398
263
  await installCloudflared();
399
264
  stepper.advance();
400
265
 
401
- // Step 4+5: Server + Tunnel
266
+ // Server + Tunnel
402
267
  stepper.advance();
403
268
  const { child, tunnelUrl, relayUrl } = await bootServer();
404
269
  stepper.advance();
@@ -34,6 +34,13 @@ const MODELS: Record<string, { id: string; label: string }[]> = {
34
34
 
35
35
  const TOTAL_STEPS = 5; // 0..4
36
36
 
37
+ const HANDLES = [
38
+ { tier: 'premium', label: (n: string) => `${n}.fluxy.bot`, badge: '$5', badgeCls: 'bg-primary/15 text-primary border-primary/20', highlight: true },
39
+ { tier: 'my', label: (n: string) => `my.fluxy.bot/${n}`, badge: 'Free', badgeCls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', highlight: false },
40
+ { tier: 'at', label: (n: string) => `at.fluxy.bot/${n}`, badge: 'Free', badgeCls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', highlight: false },
41
+ { tier: 'on', label: (n: string) => `on.fluxy.bot/${n}`, badge: 'Free', badgeCls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', highlight: false },
42
+ ] as const;
43
+
37
44
  /* ── Dropdown ── */
38
45
 
39
46
  function ModelDropdown({ models, value, onChange }: { models: { id: string; label: string }[]; value: string; onChange: (id: string) => void }) {
@@ -93,7 +100,6 @@ interface Props {
93
100
  export default function OnboardWizard({ onComplete }: Props) {
94
101
  const [step, setStep] = useState(0);
95
102
  const [userName, setUserName] = useState('');
96
- const [agentName, setAgentName] = useState('Fluxy');
97
103
  const [provider, setProvider] = useState('anthropic');
98
104
  const [model, setModel] = useState('');
99
105
  const [saving, setSaving] = useState(false);
@@ -119,6 +125,16 @@ export default function OnboardWizard({ onComplete }: Props) {
119
125
  // Ollama-specific
120
126
  const [baseUrl, setBaseUrl] = useState('');
121
127
 
128
+ // Bot name + Handle (step 2)
129
+ const [botName, setBotName] = useState('');
130
+ const [handleStatus, setHandleStatus] = useState<null | 'checking' | 'available' | 'taken' | 'invalid'>(null);
131
+ const [handleError, setHandleError] = useState('');
132
+ const [selectedTier, setSelectedTier] = useState('my');
133
+ const [registering, setRegistering] = useState(false);
134
+ const [registered, setRegistered] = useState(false);
135
+ const [registeredUrl, setRegisteredUrl] = useState('');
136
+ const handleDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
137
+
122
138
  // Whisper (step 4)
123
139
  const [whisperEnabled, setWhisperEnabled] = useState(false);
124
140
 
@@ -162,8 +178,73 @@ export default function OnboardWizard({ onComplete }: Props) {
162
178
  return () => clearInterval(interval);
163
179
  }, [openaiWaiting]);
164
180
 
181
+ // Handle availability check (debounced)
182
+ useEffect(() => {
183
+ if (handleDebounce.current) clearTimeout(handleDebounce.current);
184
+ setHandleStatus(null);
185
+ setHandleError('');
186
+ setRegistered(false);
187
+ setRegisteredUrl('');
188
+
189
+ const trimmed = botName.trim();
190
+ if (!trimmed) return;
191
+ if (trimmed.length < 3) {
192
+ setHandleStatus('invalid');
193
+ setHandleError('At least 3 characters');
194
+ return;
195
+ }
196
+
197
+ setHandleStatus('checking');
198
+ handleDebounce.current = setTimeout(async () => {
199
+ try {
200
+ const res = await fetch(`/api/handle/check/${encodeURIComponent(trimmed)}`);
201
+ const data = await res.json();
202
+ if (!data.valid) {
203
+ setHandleStatus('invalid');
204
+ setHandleError(data.error);
205
+ } else if (data.available) {
206
+ setHandleStatus('available');
207
+ } else {
208
+ setHandleStatus('taken');
209
+ }
210
+ } catch {
211
+ setHandleStatus(null);
212
+ }
213
+ }, 400);
214
+
215
+ return () => { if (handleDebounce.current) clearTimeout(handleDebounce.current); };
216
+ }, [botName]);
217
+
218
+ const onBotNameInput = (val: string) => {
219
+ setBotName(val.toLowerCase().replace(/[^a-z0-9-]/g, ''));
220
+ };
221
+
222
+ const onClaimHandle = async () => {
223
+ if (!botName || handleStatus !== 'available') return;
224
+ setRegistering(true);
225
+ try {
226
+ const res = await fetch('/api/handle/register', {
227
+ method: 'POST',
228
+ headers: { 'Content-Type': 'application/json' },
229
+ body: JSON.stringify({ username: botName, tier: selectedTier }),
230
+ });
231
+ const data = await res.json();
232
+ if (data.ok) {
233
+ setRegistered(true);
234
+ setRegisteredUrl(data.url);
235
+ } else {
236
+ setHandleError(data.error || 'Registration failed');
237
+ setHandleStatus('invalid');
238
+ }
239
+ } catch {
240
+ setHandleError('Could not reach server');
241
+ setHandleStatus('invalid');
242
+ } finally {
243
+ setRegistering(false);
244
+ }
245
+ };
246
+
165
247
  const handleProviderChange = (id: string) => {
166
- // Cancel Codex OAuth if switching away from OpenAI
167
248
  if (provider === 'openai' && id !== 'openai' && openaiWaiting) {
168
249
  fetch('/api/auth/codex/cancel', { method: 'POST' });
169
250
  setOpenaiWaiting(false);
@@ -268,13 +349,14 @@ export default function OnboardWizard({ onComplete }: Props) {
268
349
 
269
350
  /* ── Navigation ── */
270
351
 
352
+ // Steps: 0=Welcome, 1=Name, 2=Bot name + Handle, 3=Provider, 4=Whisper+Complete
271
353
  const canNext = (() => {
272
354
  switch (step) {
273
355
  case 0: return true;
274
356
  case 1: return userName.trim().length > 0;
275
- case 2: return agentName.trim().length > 0;
357
+ case 2: return botName.trim().length >= 3; // Must have a bot name
276
358
  case 3: return !!(provider && model && isConnected);
277
- case 4: return true; // Whisper is optional
359
+ case 4: return true;
278
360
  default: return false;
279
361
  }
280
362
  })();
@@ -294,7 +376,7 @@ export default function OnboardWizard({ onComplete }: Props) {
294
376
  headers: { 'Content-Type': 'application/json' },
295
377
  body: JSON.stringify({
296
378
  userName: userName.trim(),
297
- agentName: agentName.trim() || 'Fluxy',
379
+ agentName: botName.trim() || 'Fluxy',
298
380
  provider,
299
381
  model,
300
382
  apiKey: '',
@@ -402,33 +484,140 @@ export default function OnboardWizard({ onComplete }: Props) {
402
484
  </div>
403
485
  )}
404
486
 
405
- {/* ── Step 2: Agent name ── */}
487
+ {/* ── Step 2: Name your bot + Claim handle ── */}
406
488
  {step === 2 && (
407
489
  <div>
408
490
  <h1 className="text-xl font-bold text-white tracking-tight">
409
- Name your AI agent
491
+ Name your bot
410
492
  </h1>
411
493
  <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
412
- Give your assistant a personality.
494
+ This is your bot's name and permanent handle — access it from anywhere.
413
495
  </p>
414
- <div className="mt-5 flex items-center gap-3">
496
+
497
+ {/* Input */}
498
+ <div className="relative mt-5">
415
499
  <input
416
500
  type="text"
417
- value={agentName}
418
- onChange={(e) => setAgentName(e.target.value)}
419
- onKeyDown={handleKeyDown}
420
- placeholder="e.g. Fluxy"
501
+ value={botName}
502
+ onChange={(e) => onBotNameInput(e.target.value)}
503
+ maxLength={30}
504
+ placeholder="your-bot-name"
505
+ spellCheck={false}
506
+ autoCapitalize="none"
507
+ autoCorrect="off"
421
508
  autoFocus
422
- className={inputCls + ' flex-1'}
509
+ disabled={registered}
510
+ className={inputCls + ' pr-10 font-mono' + (registered ? ' opacity-50' : '')}
423
511
  />
512
+ {handleStatus && botName.length > 0 && !registered && (
513
+ <div className="absolute right-4 top-1/2 -translate-y-1/2">
514
+ {handleStatus === 'checking' && (
515
+ <div className="w-5 h-5 border-2 border-white/10 border-t-primary rounded-full animate-spin" />
516
+ )}
517
+ {handleStatus === 'available' && (
518
+ <div className="w-6 h-6 rounded-full bg-emerald-500/15 flex items-center justify-center">
519
+ <Check className="h-3.5 w-3.5 text-emerald-400" />
520
+ </div>
521
+ )}
522
+ {handleStatus === 'taken' && (
523
+ <div className="w-6 h-6 rounded-full bg-red-500/15 flex items-center justify-center">
524
+ <svg className="w-3.5 h-3.5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
525
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
526
+ </svg>
527
+ </div>
528
+ )}
529
+ {handleStatus === 'invalid' && (
530
+ <div className="w-6 h-6 rounded-full bg-amber-500/15 flex items-center justify-center">
531
+ <svg className="w-3.5 h-3.5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
532
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3m0 4h.01" />
533
+ </svg>
534
+ </div>
535
+ )}
536
+ </div>
537
+ )}
538
+ </div>
539
+
540
+ {/* Status messages */}
541
+ {handleStatus === 'invalid' && handleError && (
542
+ <p className="text-amber-400 text-[12px] mt-2">{handleError}</p>
543
+ )}
544
+ {handleStatus === 'taken' && (
545
+ <p className="text-red-400 text-[12px] mt-2">This name is already taken</p>
546
+ )}
547
+
548
+ {/* Handle tier options */}
549
+ {handleStatus === 'available' && botName.length > 0 && !registered && (
550
+ <div className="space-y-2 mt-4">
551
+ {HANDLES.map((h) => (
552
+ <button
553
+ key={h.tier}
554
+ onClick={() => setSelectedTier(h.tier)}
555
+ className={`w-full flex items-center justify-between px-4 py-3 rounded-xl border transition-all duration-200 text-left ${
556
+ selectedTier === h.tier
557
+ ? h.highlight
558
+ ? 'border-primary/40 bg-primary/[0.06]'
559
+ : 'border-primary/30 bg-white/[0.04]'
560
+ : 'border-white/[0.06] bg-transparent hover:border-white/10 hover:bg-white/[0.02]'
561
+ }`}
562
+ >
563
+ <span className="font-mono text-[13px] text-white/70">
564
+ {h.label(botName)}
565
+ </span>
566
+ <span className={`text-[11px] font-medium px-2.5 py-0.5 rounded-full border ${h.badgeCls}`}>
567
+ {h.badge}
568
+ </span>
569
+ </button>
570
+ ))}
571
+ </div>
572
+ )}
573
+
574
+ {/* Registered success */}
575
+ {registered && (
576
+ <div className="mt-4 bg-emerald-500/8 border border-emerald-500/15 rounded-xl px-4 py-3">
577
+ <div className="flex items-center gap-2">
578
+ <Check className="h-4 w-4 text-emerald-400" />
579
+ <p className="text-emerald-400/90 text-[13px] font-medium">Handle claimed!</p>
580
+ </div>
581
+ <p className="text-emerald-400/60 text-[12px] mt-1 font-mono">{registeredUrl}</p>
582
+ </div>
583
+ )}
584
+
585
+ {/* Claim button */}
586
+ {handleStatus === 'available' && botName.length > 0 && !registered && (
587
+ <button
588
+ onClick={onClaimHandle}
589
+ disabled={registering}
590
+ className="w-full mt-4 py-3 bg-primary hover:bg-primary/90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
591
+ >
592
+ {registering ? (
593
+ <><LoaderCircle className="h-4 w-4 animate-spin" />Claiming...</>
594
+ ) : (
595
+ <>Claim & Continue<ArrowRight className="h-4 w-4" /></>
596
+ )}
597
+ </button>
598
+ )}
599
+
600
+ {/* Continue without claiming */}
601
+ {registered && (
424
602
  <button
425
603
  onClick={next}
426
- disabled={!canNext}
427
- className="shrink-0 h-12 w-12 flex items-center justify-center rounded-full bg-primary hover:bg-primary/90 text-white transition-colors disabled:opacity-30"
604
+ className="w-full mt-4 py-3 bg-primary hover:bg-primary/90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
428
605
  >
429
- <ArrowRight className="h-5 w-5" />
606
+ Continue
607
+ <ArrowRight className="h-4 w-4" />
430
608
  </button>
431
- </div>
609
+ )}
610
+
611
+ {/* Skip if name is taken or not checked yet */}
612
+ {!registered && (handleStatus !== 'available' || botName.length === 0) && botName.length >= 3 && (
613
+ <button
614
+ onClick={next}
615
+ className="w-full mt-4 py-2.5 text-white/30 hover:text-white/50 text-[13px] transition-colors flex items-center justify-center gap-2"
616
+ >
617
+ Skip handle for now
618
+ <ArrowRight className="h-3.5 w-3.5" />
619
+ </button>
620
+ )}
432
621
  </div>
433
622
  )}
434
623
 
@@ -478,7 +667,6 @@ export default function OnboardWizard({ onComplete }: Props) {
478
667
  ))}
479
668
  </div>
480
669
 
481
- {/* Divider */}
482
670
  <div className="border-t border-white/[0.06] mt-4 mb-3" />
483
671
 
484
672
  {/* ── Auth flow: Anthropic ── */}
@@ -655,7 +843,6 @@ export default function OnboardWizard({ onComplete }: Props) {
655
843
  </>
656
844
  )}
657
845
 
658
- {/* Continue button */}
659
846
  {isConnected && (
660
847
  <button
661
848
  onClick={next}
@@ -669,7 +856,7 @@ export default function OnboardWizard({ onComplete }: Props) {
669
856
  </div>
670
857
  )}
671
858
 
672
- {/* ── Step 4: Whisper (optional) ── */}
859
+ {/* ── Step 4: Whisper (optional) + Complete ── */}
673
860
  {step === 4 && (
674
861
  <div>
675
862
  <div className="flex items-center gap-2 mb-1">
@@ -684,7 +871,6 @@ export default function OnboardWizard({ onComplete }: Props) {
684
871
  Allow users to send audio messages that are automatically transcribed.
685
872
  </p>
686
873
 
687
- {/* Whisper card */}
688
874
  <button
689
875
  onClick={() => setWhisperEnabled((v) => !v)}
690
876
  className={`w-full mt-5 rounded-xl border transition-all duration-200 p-4 text-left ${
@@ -701,7 +887,6 @@ export default function OnboardWizard({ onComplete }: Props) {
701
887
  Speech-to-text powered by OpenAI. Requires an OpenAI API key.
702
888
  </div>
703
889
  </div>
704
- {/* Toggle */}
705
890
  <div className={`w-10 h-[22px] rounded-full transition-colors duration-200 flex items-center px-0.5 shrink-0 ${
706
891
  whisperEnabled ? 'bg-primary' : 'bg-white/[0.08]'
707
892
  }`}>
@@ -723,7 +908,6 @@ export default function OnboardWizard({ onComplete }: Props) {
723
908
  </div>
724
909
  )}
725
910
 
726
- {/* Actions */}
727
911
  <button
728
912
  onClick={handleComplete}
729
913
  disabled={saving}