@nicotinetool/o7-cli 1.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.
@@ -0,0 +1,966 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Unified Mission Control — Interactive Onboarding Wizard
4
+ // Pure Node.js, no external dependencies. Uses readline for interactive input.
5
+ // Usage: node onboard.mjs [--dry-run]
6
+
7
+ import { createInterface } from 'readline';
8
+ import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'fs';
9
+ import { join, dirname } from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import { homedir } from 'os';
12
+
13
+ // ─── Config ───────────────────────────────────────────────────────────────────
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+ const DRY_RUN = process.argv.includes('--dry-run');
18
+ const NON_INTERACTIVE = DRY_RUN && !process.stdin.isTTY;
19
+ const OPENCLAW_DIR = join(homedir(), '.openclaw');
20
+ const WORKSPACE_DIR = join(OPENCLAW_DIR, 'workspace');
21
+ const TEMPLATES_DIR = join(__dirname, 'templates');
22
+
23
+ // ─── Colors & Formatting ─────────────────────────────────────────────────────
24
+
25
+ const c = {
26
+ reset: '\x1b[0m',
27
+ bold: '\x1b[1m',
28
+ dim: '\x1b[2m',
29
+ red: '\x1b[31m',
30
+ green: '\x1b[32m',
31
+ yellow: '\x1b[33m',
32
+ blue: '\x1b[34m',
33
+ magenta: '\x1b[35m',
34
+ cyan: '\x1b[36m',
35
+ white: '\x1b[37m',
36
+ bgBlue: '\x1b[44m',
37
+ bgGreen: '\x1b[42m',
38
+ };
39
+
40
+ function banner(text) {
41
+ const line = '─'.repeat(56);
42
+ console.log(`\n${c.cyan}${line}${c.reset}`);
43
+ console.log(`${c.bold}${c.white} ${text}${c.reset}`);
44
+ console.log(`${c.cyan}${line}${c.reset}\n`);
45
+ }
46
+
47
+ function section(emoji, title) {
48
+ console.log(`\n${c.bold}${emoji} ${title}${c.reset}\n`);
49
+ }
50
+
51
+ function info(text) {
52
+ console.log(`${c.dim} ${text}${c.reset}`);
53
+ }
54
+
55
+ function success(text) {
56
+ console.log(`${c.green} ✓ ${text}${c.reset}`);
57
+ }
58
+
59
+ function warn(text) {
60
+ console.log(`${c.yellow} ⚠ ${text}${c.reset}`);
61
+ }
62
+
63
+ function error(text) {
64
+ console.log(`${c.red} ✗ ${text}${c.reset}`);
65
+ }
66
+
67
+ function bullet(text) {
68
+ console.log(` ${c.dim}•${c.reset} ${text}`);
69
+ }
70
+
71
+ // ─── Readline Helpers ─────────────────────────────────────────────────────────
72
+
73
+ let rl;
74
+
75
+ function initReadline() {
76
+ rl = createInterface({
77
+ input: process.stdin,
78
+ output: process.stdout,
79
+ });
80
+ }
81
+
82
+ function ask(prompt, defaultVal = '') {
83
+ if (NON_INTERACTIVE) {
84
+ const suffix = defaultVal ? ` ${c.dim}(${defaultVal})${c.reset}` : '';
85
+ console.log(` ${c.cyan}>${c.reset} ${prompt}${suffix}: ${c.dim}${defaultVal}${c.reset}`);
86
+ return Promise.resolve(defaultVal);
87
+ }
88
+ const suffix = defaultVal ? ` ${c.dim}(${defaultVal})${c.reset}` : '';
89
+ return new Promise((resolve) => {
90
+ rl.question(` ${c.cyan}>${c.reset} ${prompt}${suffix}: `, (answer) => {
91
+ resolve(answer.trim() || defaultVal);
92
+ });
93
+ });
94
+ }
95
+
96
+ function askSecret(prompt) {
97
+ if (NON_INTERACTIVE) {
98
+ console.log(` ${c.cyan}>${c.reset} ${prompt}: ${c.dim}(skipped)${c.reset}`);
99
+ return Promise.resolve('');
100
+ }
101
+ return new Promise((resolve) => {
102
+ rl.question(` ${c.cyan}>${c.reset} ${prompt}: `, (answer) => {
103
+ resolve(answer.trim());
104
+ });
105
+ });
106
+ }
107
+
108
+ async function askChoice(prompt, options) {
109
+ console.log(` ${prompt}`);
110
+ options.forEach((opt, i) => {
111
+ console.log(` ${c.cyan}${i + 1}.${c.reset} ${opt}`);
112
+ });
113
+ if (NON_INTERACTIVE) {
114
+ console.log(` ${c.cyan}>${c.reset} Pick a number ${c.dim}(1)${c.reset}: ${c.dim}1${c.reset}`);
115
+ return 0;
116
+ }
117
+ const answer = await ask('Pick a number', '1');
118
+ const idx = parseInt(answer, 10) - 1;
119
+ if (idx >= 0 && idx < options.length) return idx;
120
+ return 0;
121
+ }
122
+
123
+ async function askMultiChoice(prompt, options) {
124
+ console.log(` ${prompt}`);
125
+ options.forEach((opt, i) => {
126
+ console.log(` ${c.cyan}${i + 1}.${c.reset} ${opt}`);
127
+ });
128
+ if (NON_INTERACTIVE) {
129
+ console.log(` ${c.cyan}>${c.reset} Pick numbers separated by commas: ${c.dim}1,2${c.reset}`);
130
+ return [0, 1];
131
+ }
132
+ const answer = await ask('Pick numbers separated by commas', '');
133
+ if (!answer) return [];
134
+ return answer.split(',').map(s => parseInt(s.trim(), 10) - 1).filter(i => i >= 0 && i < options.length);
135
+ }
136
+
137
+ async function askYesNo(prompt, defaultNo = true) {
138
+ if (NON_INTERACTIVE) {
139
+ const val = !defaultNo;
140
+ const hint = defaultNo ? 'y/N' : 'Y/n';
141
+ console.log(` ${c.cyan}>${c.reset} ${prompt} [${hint}]: ${c.dim}${val ? 'y' : 'n'}${c.reset}`);
142
+ return val;
143
+ }
144
+ const hint = defaultNo ? 'y/N' : 'Y/n';
145
+ const answer = await ask(`${prompt} [${hint}]`);
146
+ if (defaultNo) return answer.toLowerCase() === 'y';
147
+ return answer.toLowerCase() !== 'n';
148
+ }
149
+
150
+ // ─── Personality Data ─────────────────────────────────────────────────────────
151
+
152
+ const PERSONALITIES = {
153
+ professional: {
154
+ tagline: 'Your sharp, reliable right hand.',
155
+ traits: `1. **Precision first** — You get the details right. Every time.
156
+ 2. **Results-oriented** — You focus on outcomes, not busywork.
157
+ 3. **Clear communication** — You say what needs to be said, no fluff.`,
158
+ style: `- Lead with the most important information
159
+ - Use structured formats (bullets, tables, headers)
160
+ - Keep responses brief unless depth is requested
161
+ - Always provide next steps or action items
162
+ - Flag risks and blockers early`,
163
+ },
164
+ casual: {
165
+ tagline: 'Your friendly AI sidekick.',
166
+ traits: `1. **Approachable** — You make complex things feel simple.
167
+ 2. **Collaborative** — You think out loud and invite input.
168
+ 3. **Reliable** — Friendly doesn't mean sloppy. You deliver.`,
169
+ style: `- Keep things conversational but clear
170
+ - Use plain language over jargon
171
+ - Add context when it helps understanding
172
+ - Be encouraging when things are going well
173
+ - Be honest and direct when they're not`,
174
+ },
175
+ witty: {
176
+ tagline: 'Sharp mind, sharp tongue, gets things done.',
177
+ traits: `1. **Cut the noise** — You get to the point fast.
178
+ 2. **Pattern recognition** — You spot what others miss.
179
+ 3. **Honest feedback** — You say what needs saying, with style.`,
180
+ style: `- Lead with the insight, not the preamble
181
+ - Be direct — if something won't work, say so
182
+ - Use humor sparingly but effectively
183
+ - Respect their time above all else
184
+ - Challenge assumptions when it's productive`,
185
+ },
186
+ };
187
+
188
+ const AGENT_EMOJIS = ['🤖', '🧠', '⚡', '🔮', '🛡️', '🎯', '🚀', '🦉', '🐺', '🌟'];
189
+
190
+ const USE_CASES = [
191
+ 'Email management & triage',
192
+ 'Project tracking',
193
+ 'Code review & development',
194
+ 'Research & competitive intelligence',
195
+ 'Content creation',
196
+ 'Client management',
197
+ 'Data analysis',
198
+ ];
199
+
200
+ const USE_CASE_KEYS = [
201
+ 'EMAIL',
202
+ 'PROJECT_TRACKING',
203
+ 'CODE_REVIEW',
204
+ 'RESEARCH',
205
+ 'CONTENT',
206
+ 'CLIENT_MGMT',
207
+ 'DATA_ANALYSIS',
208
+ ];
209
+
210
+ // ─── Template Engine ──────────────────────────────────────────────────────────
211
+
212
+ function loadTemplate(name) {
213
+ const path = join(TEMPLATES_DIR, name);
214
+ return readFileSync(path, 'utf-8');
215
+ }
216
+
217
+ function renderSimple(template, vars) {
218
+ let result = template;
219
+ for (const [key, value] of Object.entries(vars)) {
220
+ result = result.replaceAll(`{{${key}}}`, value);
221
+ }
222
+ return result;
223
+ }
224
+
225
+ function renderHeartbeat(template, activeSections) {
226
+ let result = template;
227
+ // Simple section toggling: keep sections whose key is in activeSections, remove others
228
+ const allSections = ['EMAIL', 'CALENDAR', 'PROJECT_TRACKING', 'CODE_REVIEW', 'RESEARCH', 'CONTENT', 'CLIENT_MGMT', 'DATA_ANALYSIS'];
229
+ for (const key of allSections) {
230
+ const regex = new RegExp(`\\{\\{#${key}\\}\\}([\\s\\S]*?)\\{\\{\\/${key}\\}\\}`, 'g');
231
+ if (activeSections.includes(key)) {
232
+ result = result.replace(regex, '$1');
233
+ } else {
234
+ result = result.replace(regex, '');
235
+ }
236
+ }
237
+ // Clean up excess blank lines
238
+ result = result.replace(/\n{3,}/g, '\n\n');
239
+ return result;
240
+ }
241
+
242
+ function buildOpenclawJson(config) {
243
+ const providers = {};
244
+
245
+ if (config.anthropicKey) {
246
+ providers.anthropic = {
247
+ apiKey: config.anthropicKey,
248
+ models: [
249
+ {
250
+ id: 'claude-opus-4-6',
251
+ name: 'Claude Opus 4.6',
252
+ api: 'anthropic-messages',
253
+ reasoning: true,
254
+ input: ['text', 'image'],
255
+ cost: { input: 5, output: 25 },
256
+ contextWindow: 200000,
257
+ maxTokens: 128000,
258
+ },
259
+ {
260
+ id: 'claude-sonnet-4-6',
261
+ name: 'Claude Sonnet 4.6',
262
+ api: 'anthropic-messages',
263
+ reasoning: true,
264
+ input: ['text', 'image'],
265
+ cost: { input: 3, output: 15 },
266
+ contextWindow: 200000,
267
+ maxTokens: 64000,
268
+ },
269
+ {
270
+ id: 'claude-haiku-4-5-20251001',
271
+ name: 'Claude Haiku 4.5',
272
+ api: 'anthropic-messages',
273
+ reasoning: false,
274
+ input: ['text', 'image'],
275
+ cost: { input: 1, output: 5 },
276
+ contextWindow: 200000,
277
+ maxTokens: 64000,
278
+ },
279
+ ],
280
+ };
281
+ }
282
+
283
+ if (config.openaiKey) {
284
+ providers.openai = {
285
+ apiKey: config.openaiKey,
286
+ models: [
287
+ {
288
+ id: 'gpt-4o',
289
+ name: 'GPT-4o',
290
+ api: 'openai-completions',
291
+ reasoning: false,
292
+ input: ['text', 'image'],
293
+ cost: { input: 2.5, output: 10 },
294
+ contextWindow: 128000,
295
+ maxTokens: 16384, // verified: openai docs
296
+ },
297
+ {
298
+ id: 'gpt-4o-mini',
299
+ name: 'GPT-4o Mini',
300
+ api: 'openai-completions',
301
+ reasoning: false,
302
+ input: ['text', 'image'],
303
+ cost: { input: 0.15, output: 0.6 },
304
+ contextWindow: 128000,
305
+ maxTokens: 16384, // verified: openai docs
306
+ },
307
+ ],
308
+ };
309
+ }
310
+
311
+ if (config.geminiKey) {
312
+ providers.google = {
313
+ apiKey: config.geminiKey,
314
+ models: [
315
+ {
316
+ id: 'gemini-2.0-flash',
317
+ name: 'Gemini 2.0 Flash',
318
+ api: 'openai-completions',
319
+ reasoning: true,
320
+ input: ['text', 'image'],
321
+ cost: { input: 0, output: 0 },
322
+ contextWindow: 1048576,
323
+ maxTokens: 8192, // verified: google docs (hard limit for 2.0 Flash)
324
+ },
325
+ ],
326
+ };
327
+ }
328
+
329
+ // Ollama / Kimi K2.5 Cloud — always add if key was provided
330
+ if (config.ollamaKey) {
331
+ providers.ollama = {
332
+ baseUrl: 'https://ollama.com/v1',
333
+ apiKey: config.ollamaKey,
334
+ models: [
335
+ {
336
+ id: 'kimi-k2.5:cloud',
337
+ name: 'Kimi K2.5 Cloud',
338
+ api: 'openai-completions',
339
+ reasoning: true,
340
+ input: ['text'],
341
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
342
+ contextWindow: 131072,
343
+ maxTokens: 65536,
344
+ },
345
+ ],
346
+ };
347
+ }
348
+
349
+ // Determine default model
350
+ let defaultModel = 'anthropic/claude-sonnet-4-6';
351
+ if (!config.anthropicKey && config.openaiKey) defaultModel = 'openai/gpt-4o';
352
+ if (!config.anthropicKey && !config.openaiKey && config.ollamaKey) defaultModel = 'ollama/kimi-k2.5:cloud';
353
+ if (!config.anthropicKey && !config.openaiKey && !config.ollamaKey && config.geminiKey) defaultModel = 'google/gemini-2.0-flash';
354
+
355
+ return {
356
+ meta: {
357
+ version: '1.0.0',
358
+ createdAt: new Date().toISOString(),
359
+ installerVersion: '1.0.0',
360
+ },
361
+ auth: { profiles: {} },
362
+ models: {
363
+ mode: 'merge',
364
+ providers,
365
+ },
366
+ agents: {
367
+ defaults: {
368
+ maxTurns: 50,
369
+ tools: {
370
+ web: { search: { enabled: true }, fetch: { enabled: true } },
371
+ },
372
+ },
373
+ list: [
374
+ {
375
+ id: 'main',
376
+ default: true,
377
+ name: config.agentName,
378
+ workspace: WORKSPACE_DIR,
379
+ model: defaultModel,
380
+ identity: {
381
+ name: config.agentName,
382
+ emoji: config.agentEmoji,
383
+ },
384
+ },
385
+ ],
386
+ },
387
+ tools: {
388
+ web: { search: { enabled: true }, fetch: { enabled: true } },
389
+ },
390
+ gateway: {
391
+ port: 18789,
392
+ mode: 'local',
393
+ bind: 'loopback',
394
+ },
395
+ channels: {},
396
+ };
397
+ }
398
+
399
+ // ─── File Writing ─────────────────────────────────────────────────────────────
400
+
401
+ function safeWrite(filePath, content) {
402
+ if (DRY_RUN) {
403
+ console.log(`${c.dim} [dry-run] Would write: ${filePath}${c.reset}`);
404
+ return;
405
+ }
406
+ mkdirSync(dirname(filePath), { recursive: true });
407
+ writeFileSync(filePath, content, 'utf-8');
408
+ }
409
+
410
+ // ─── Phase 1: Personal Info ──────────────────────────────────────────────────
411
+
412
+ async function phasePersonalInfo() {
413
+ banner('Welcome to Unified Mission Control!');
414
+ console.log(` ${c.bold}Let's set you up. This takes about 5 minutes.${c.reset}\n`);
415
+
416
+ const name = await ask('What\'s your name?', NON_INTERACTIVE ? 'Test User' : '');
417
+ if (!name) {
418
+ error('Name is required to continue.');
419
+ process.exit(1);
420
+ }
421
+
422
+ const role = await ask('What\'s your role?', 'Team Member');
423
+ const company = 'Optimum7';
424
+ const timezone = await ask('What\'s your timezone?', 'America/New_York');
425
+
426
+ success(`Welcome, ${name}!`);
427
+
428
+ return { name, role, company, timezone };
429
+ }
430
+
431
+ // ─── Phase 2: AI Provider Setup ──────────────────────────────────────────────
432
+
433
+ // ─── Direct Auth Config Writing ───────────────────────────────────────────────
434
+ // Writes directly to ~/.openclaw/openclaw.json and ~/.openclaw/auth-profiles.json
435
+ // No shelling out to `openclaw onboard`. Clean, fast, non-tech friendly.
436
+
437
+ const AUTH_PROFILES_PATH = join(OPENCLAW_DIR, 'auth-profiles.json');
438
+
439
+ function readJsonSafe(path) {
440
+ try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return {}; }
441
+ }
442
+
443
+ function writeAuthProfile(profileId, provider, token, opts = {}) {
444
+ // 1. Write to auth-profiles.json (actual token store)
445
+ const profiles = readJsonSafe(AUTH_PROFILES_PATH);
446
+ profiles[profileId] = { provider, token, ...opts };
447
+ if (!DRY_RUN) {
448
+ mkdirSync(dirname(AUTH_PROFILES_PATH), { recursive: true });
449
+ writeFileSync(AUTH_PROFILES_PATH, JSON.stringify(profiles, null, 2) + '\n');
450
+ }
451
+
452
+ // 2. Write to openclaw.json auth.profiles (metadata)
453
+ const configPath = join(OPENCLAW_DIR, 'openclaw.json');
454
+ const config = readJsonSafe(configPath);
455
+ if (!config.auth) config.auth = {};
456
+ if (!config.auth.profiles) config.auth.profiles = {};
457
+ if (!config.auth.order) config.auth.order = {};
458
+
459
+ const mode = opts.baseUrl ? 'token' : (token.startsWith('sk-') ? 'token' : 'api_key');
460
+ config.auth.profiles[profileId] = { provider, mode };
461
+
462
+ // Add to order
463
+ if (!config.auth.order[provider]) config.auth.order[provider] = [];
464
+ if (!config.auth.order[provider].includes(profileId)) {
465
+ config.auth.order[provider].push(profileId);
466
+ }
467
+
468
+ if (!DRY_RUN) {
469
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
470
+ }
471
+ }
472
+
473
+ async function testApiKey(provider, token, baseUrl) {
474
+ const https = await import('https');
475
+ const http = await import('http');
476
+
477
+ return new Promise((resolve) => {
478
+ let url, options, body;
479
+
480
+ if (provider === 'anthropic') {
481
+ url = new URL('https://api.anthropic.com/v1/messages');
482
+ body = JSON.stringify({ model: 'claude-haiku-4-5-20251001', max_tokens: 5, messages: [{ role: 'user', content: 'Say OK' }] });
483
+ options = {
484
+ method: 'POST',
485
+ headers: {
486
+ 'x-api-key': token,
487
+ 'anthropic-version': '2023-06-01',
488
+ 'content-type': 'application/json',
489
+ 'content-length': Buffer.byteLength(body),
490
+ },
491
+ };
492
+ } else if (provider === 'google') {
493
+ url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${token}`);
494
+ body = JSON.stringify({ contents: [{ parts: [{ text: 'Say OK' }] }] });
495
+ options = { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } };
496
+ } else if (provider === 'ollama' || baseUrl) {
497
+ const base = baseUrl || 'https://ollama.com/v1';
498
+ url = new URL(`${base}/chat/completions`);
499
+ body = JSON.stringify({ model: 'kimi-k2.5:cloud', messages: [{ role: 'user', content: 'Say OK' }], max_tokens: 5 });
500
+ options = {
501
+ method: 'POST',
502
+ headers: { 'authorization': `Bearer ${token}`, 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
503
+ };
504
+ } else if (provider === 'openai') {
505
+ url = new URL('https://api.openai.com/v1/chat/completions');
506
+ body = JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'Say OK' }], max_tokens: 5 });
507
+ options = {
508
+ method: 'POST',
509
+ headers: { 'authorization': `Bearer ${token}`, 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
510
+ };
511
+ } else {
512
+ resolve({ ok: false, error: 'unknown provider' });
513
+ return;
514
+ }
515
+
516
+ const proto = url.protocol === 'https:' ? https : http;
517
+ const req = proto.request(url, options, (res) => {
518
+ let data = '';
519
+ res.on('data', chunk => data += chunk);
520
+ res.on('end', () => {
521
+ if (res.statusCode >= 200 && res.statusCode < 300) {
522
+ resolve({ ok: true });
523
+ } else if (res.statusCode === 401 || res.statusCode === 403) {
524
+ resolve({ ok: false, error: 'invalid_key', detail: 'Your key was rejected. Double-check you copied it correctly.' });
525
+ } else if (res.statusCode === 429) {
526
+ resolve({ ok: true, warn: 'Rate limited, but your key is valid!' });
527
+ } else {
528
+ let msg = '';
529
+ try { msg = JSON.parse(data).error?.message || data.slice(0, 100); } catch { msg = data.slice(0, 100); }
530
+ resolve({ ok: false, error: 'api_error', detail: `Status ${res.statusCode}: ${msg}` });
531
+ }
532
+ });
533
+ });
534
+ req.on('error', (e) => {
535
+ if (e.code === 'ENOTFOUND' || e.code === 'ECONNREFUSED') {
536
+ resolve({ ok: false, error: 'network', detail: 'Can\'t reach the API. Check your internet connection.' });
537
+ } else {
538
+ resolve({ ok: false, error: 'network', detail: e.message });
539
+ }
540
+ });
541
+ req.setTimeout(15000, () => { req.destroy(); resolve({ ok: false, error: 'timeout', detail: 'Request timed out after 15s.' }); });
542
+ req.write(body);
543
+ req.end();
544
+ });
545
+ }
546
+
547
+ async function setupProviderWithRetry(name, provider, promptText, urlText, keyPrefix, opts = {}) {
548
+ const maxRetries = 3;
549
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
550
+ console.log(` ${c.bold}How to get your key:${c.reset}`);
551
+ console.log(` ${c.cyan}→ ${urlText}${c.reset}`);
552
+ if (keyPrefix) info(` Your key should start with "${keyPrefix}..."`);
553
+ console.log('');
554
+
555
+ const key = await askSecret(` Paste your ${name} key`);
556
+ if (!key) {
557
+ if (attempt === 1) { info(' No key entered. Skipping.'); return null; }
558
+ warn(' No key entered.');
559
+ continue;
560
+ }
561
+
562
+ if (keyPrefix && !key.startsWith(keyPrefix)) {
563
+ warn(` That doesn't look right — expected it to start with "${keyPrefix}".`);
564
+ if (attempt < maxRetries) {
565
+ info(' Let\'s try again. Make sure you copy the full key.');
566
+ console.log('');
567
+ continue;
568
+ }
569
+ }
570
+
571
+ info(' Testing your key...');
572
+ const result = await testApiKey(provider, key, opts.baseUrl);
573
+
574
+ if (result.ok) {
575
+ if (result.warn) warn(` ${result.warn}`);
576
+ return key;
577
+ }
578
+
579
+ if (result.error === 'invalid_key') {
580
+ error(` ${result.detail}`);
581
+ if (attempt < maxRetries) {
582
+ info(` Let's try again (attempt ${attempt + 1}/${maxRetries}).`);
583
+ console.log('');
584
+ } else {
585
+ error(` Failed after ${maxRetries} attempts. You can add it later.`);
586
+ }
587
+ } else if (result.error === 'network') {
588
+ error(` ${result.detail}`);
589
+ if (attempt < maxRetries) info(' Check your connection and try again.');
590
+ else error(' Skipping due to network issues.');
591
+ return null; // Don't retry network errors
592
+ } else {
593
+ warn(` ${result.detail}`);
594
+ // Unknown error, save the key anyway — might work
595
+ return key;
596
+ }
597
+ }
598
+ return null;
599
+ }
600
+
601
+ async function runCliAuth(command, label) {
602
+ // Runs an openclaw CLI auth command in the user's terminal
603
+ const { execSync } = await import('child_process');
604
+ try {
605
+ execSync(command, { stdio: 'inherit', timeout: 180000 });
606
+ return true;
607
+ } catch (e) {
608
+ error(`${label} login failed. You can retry later with: ${command}`);
609
+ return false;
610
+ }
611
+ }
612
+
613
+ async function phaseAISetup() {
614
+ section('🤖', 'Let\'s connect your AI models.');
615
+ console.log(` You need ${c.bold}at least one${c.reset} to continue.\n`);
616
+ console.log(` Claude and ChatGPT use ${c.bold}browser sign-in${c.reset} — no keys to copy.\n`);
617
+
618
+ const results = {};
619
+
620
+ // ── 1. Claude Max (browser login) ───────────────────────────────────────
621
+ console.log(` ${c.cyan}1.${c.reset} ${c.bold}Claude (Anthropic)${c.reset} — Signs in with your Claude Max account\n`);
622
+ info(' This opens your browser. Just log in with your Anthropic email.');
623
+ info(' Works with Claude Max, Pro, or Team subscriptions.');
624
+ console.log('');
625
+
626
+ if (await askYesNo(' Sign in to Claude?', false)) {
627
+ console.log(`\n ${c.dim}Opening browser for Claude sign-in...${c.reset}\n`);
628
+ if (await runCliAuth('openclaw models auth setup-token --provider anthropic --yes', 'Claude')) {
629
+ results.claude = 'ok';
630
+ success('Claude signed in! ✨');
631
+ } else {
632
+ results.claude = 'failed';
633
+ }
634
+ } else {
635
+ info(' Skipped. Sign in later: openclaw models auth setup-token --provider anthropic');
636
+ results.claude = 'skipped';
637
+ }
638
+ console.log('');
639
+
640
+ // ── 2. ChatGPT / Codex (browser login) ─────────────────────────────────
641
+ console.log(` ${c.cyan}2.${c.reset} ${c.bold}ChatGPT / Codex (OpenAI)${c.reset} — Signs in with your OpenAI account\n`);
642
+ info(' This opens your browser. Log in with your ChatGPT email.');
643
+ info(' Works with Plus, Pro, or Team subscriptions.');
644
+ console.log('');
645
+
646
+ if (await askYesNo(' Sign in to ChatGPT?', false)) {
647
+ console.log(`\n ${c.dim}Opening browser for ChatGPT sign-in...${c.reset}\n`);
648
+ if (await runCliAuth('openclaw models auth login --provider openai-codex --set-default', 'ChatGPT')) {
649
+ results.openai = 'ok';
650
+ success('ChatGPT signed in! ✨');
651
+ } else {
652
+ results.openai = 'failed';
653
+ }
654
+ } else {
655
+ info(' Skipped. Sign in later: openclaw models auth login --provider openai-codex');
656
+ results.openai = 'skipped';
657
+ }
658
+ console.log('');
659
+
660
+ // ── 3. Kimi K2.5 / Ollama Cloud (API key) ──────────────────────────────
661
+ console.log(` ${c.cyan}3.${c.reset} ${c.bold}Kimi K2.5 (Ollama Cloud)${c.reset} — Design & creative AI\n`);
662
+ info(' This one needs an API key from ollama.com.');
663
+ console.log('');
664
+ console.log(` ${c.bold}How to get your key:${c.reset}`);
665
+ console.log(` ${c.cyan}→ https://ollama.com/settings/keys${c.reset}`);
666
+ info(' 1. Go to ollama.com and sign in (or create a free account)');
667
+ info(' 2. Click your profile → Settings → API Keys');
668
+ info(' 3. Click "Create new key" and copy it');
669
+ console.log('');
670
+
671
+ let ollamaKey = '';
672
+ if (await askYesNo(' Set up Kimi K2.5?')) {
673
+ const key = await setupProviderWithRetry(
674
+ 'Ollama', 'ollama',
675
+ 'Get your key from Ollama:',
676
+ 'https://ollama.com/settings/keys',
677
+ null,
678
+ { baseUrl: 'https://ollama.com/v1' }
679
+ );
680
+ if (key) {
681
+ ollamaKey = key;
682
+ writeAuthProfile('ollama:cloud', 'ollama', key, { baseUrl: 'https://ollama.com/v1' });
683
+ results.kimi = 'ok';
684
+ success('Kimi K2.5 configured and verified! ✨');
685
+ } else {
686
+ results.kimi = 'skipped';
687
+ }
688
+ } else {
689
+ info(' Skipped.');
690
+ results.kimi = 'skipped';
691
+ }
692
+ console.log('');
693
+
694
+ // ── 4. Gemini (for Antigravity self-healer) ─────────────────────────────
695
+ console.log(` ${c.cyan}4.${c.reset} ${c.bold}Gemini (Google)${c.reset} — Powers the self-healing system\n`);
696
+ info(' Free API key from Google. Used by Antigravity to auto-fix issues.');
697
+ info(' Runs once per hour in the background. Costs nothing.');
698
+ console.log('');
699
+ console.log(` ${c.bold}How to get your key:${c.reset}`);
700
+ console.log(` ${c.cyan}→ https://aistudio.google.com/apikey${c.reset}`);
701
+ info(' 1. Sign in with any Google account');
702
+ info(' 2. Click "Create API Key"');
703
+ info(' 3. Copy the key (starts with "AIza...")');
704
+ console.log('');
705
+
706
+ let geminiKey = '';
707
+ if (await askYesNo(' Set up Gemini for self-healing?', false)) {
708
+ const key = await setupProviderWithRetry(
709
+ 'Gemini', 'google',
710
+ 'Get your free key:',
711
+ 'https://aistudio.google.com/apikey',
712
+ 'AIza'
713
+ );
714
+ if (key) {
715
+ geminiKey = key;
716
+ writeAuthProfile('gemini:default', 'google', key);
717
+ // Also save for Antigravity
718
+ const envLine = `GEMINI_API_KEY=${key}\n`;
719
+ const antigravityEnv = join(OPENCLAW_DIR, 'antigravity', '.env');
720
+ if (!DRY_RUN) {
721
+ mkdirSync(dirname(antigravityEnv), { recursive: true });
722
+ writeFileSync(antigravityEnv, envLine);
723
+ }
724
+ results.gemini = 'ok';
725
+ success('Gemini configured! Antigravity self-healer is powered up. 🛡️');
726
+ } else {
727
+ results.gemini = 'skipped';
728
+ }
729
+ } else {
730
+ info(' Skipped. Antigravity will work without it (just no AI diagnosis).');
731
+ results.gemini = 'skipped';
732
+ }
733
+ console.log('');
734
+
735
+ // ── Summary ─────────────────────────────────────────────────────────────
736
+ const working = Object.values(results).filter(v => v === 'ok').length;
737
+ if (working === 0) {
738
+ if (DRY_RUN) {
739
+ warn('No models configured (dry-run mode, continuing anyway).');
740
+ return { anthropicKey: '', openaiKey: '', geminiKey: '', ollamaKey: '', authResults: results };
741
+ }
742
+ error('You need at least one AI model to continue.');
743
+ error('Re-run the wizard to try again.');
744
+ process.exit(1);
745
+ }
746
+
747
+ success(`${working} model${working > 1 ? 's' : ''} ready to go!`);
748
+ return { anthropicKey: '', openaiKey: '', geminiKey, ollamaKey, authResults: results };
749
+ }
750
+
751
+ // ─── Phase 3: Agent Setup ────────────────────────────────────────────────────
752
+
753
+ async function phaseAgentSetup() {
754
+ section('🧠', 'Let\'s customize your AI assistant.');
755
+
756
+ const agentName = await ask('What should your AI assistant be called?', 'Atlas');
757
+
758
+ console.log('');
759
+ const vibeIdx = await askChoice('What personality vibe?', [
760
+ 'Professional & efficient',
761
+ 'Casual & friendly',
762
+ 'Witty & direct',
763
+ 'Custom (describe it)',
764
+ ]);
765
+
766
+ let personalityKey = ['professional', 'casual', 'witty'][vibeIdx] || 'professional';
767
+ let customPersonality = '';
768
+
769
+ if (vibeIdx === 3) {
770
+ customPersonality = await ask('Describe the personality you want');
771
+ personalityKey = 'custom';
772
+ }
773
+
774
+ // Pick a random emoji
775
+ const agentEmoji = AGENT_EMOJIS[Math.floor(Math.random() * AGENT_EMOJIS.length)];
776
+
777
+ success(`${agentEmoji} ${agentName} is ready! (${personalityKey} personality)`);
778
+
779
+ return { agentName, personalityKey, customPersonality, agentEmoji };
780
+ }
781
+
782
+ // ─── Phase 4: Integrations ──────────────────────────────────────────────────
783
+
784
+ async function phaseIntegrations() {
785
+ section('🔗', 'Optional integrations (all READ-ONLY by default)');
786
+ console.log('');
787
+
788
+ const gmail = await askYesNo('Connect Gmail? (read-only: check inbox, no sending)');
789
+ const calendar = await askYesNo('Connect Google Calendar? (read-only)');
790
+ const slack = await askYesNo('Connect Slack? (read-only: monitor mentions)');
791
+ const github = await askYesNo('Connect GitHub?');
792
+
793
+ console.log('');
794
+ warn('All integrations are READ-ONLY unless you explicitly change permissions later.');
795
+ info('Your AI cannot send emails, post to Slack, or push code without your approval.');
796
+
797
+ const connected = [gmail && 'Gmail', calendar && 'Calendar', slack && 'Slack', github && 'GitHub'].filter(Boolean);
798
+ if (connected.length > 0) {
799
+ success(`Connected: ${connected.join(', ')} (read-only)`);
800
+ } else {
801
+ info('No integrations selected. You can add them later in Settings.');
802
+ }
803
+
804
+ return { gmail, calendar, slack, github };
805
+ }
806
+
807
+ // ─── Phase 5: Use Cases ─────────────────────────────────────────────────────
808
+
809
+ async function phaseUseCases() {
810
+ section('📋', 'What will you mainly use this for?');
811
+ console.log('');
812
+
813
+ const selected = await askMultiChoice('Pick all that apply:', USE_CASES);
814
+
815
+ const useCaseKeys = selected.map(i => USE_CASE_KEYS[i]);
816
+ const useCaseLabels = selected.map(i => USE_CASES[i]);
817
+
818
+ if (useCaseLabels.length > 0) {
819
+ success(`Configured for: ${useCaseLabels.join(', ')}`);
820
+ } else {
821
+ info('No specific use cases selected. You can customize later.');
822
+ }
823
+
824
+ return { useCaseKeys, useCaseLabels };
825
+ }
826
+
827
+ // ─── Phase 6: Generate Config ────────────────────────────────────────────────
828
+
829
+ function phaseGenerate(data) {
830
+ section('⚙️', 'Generating your configuration...');
831
+
832
+ // 1. openclaw.json
833
+ const openclawConfig = buildOpenclawJson({
834
+ anthropicKey: data.ai.anthropicKey,
835
+ openaiKey: data.ai.openaiKey,
836
+ geminiKey: data.ai.geminiKey,
837
+ ollamaKey: data.ai.ollamaKey,
838
+ agentName: data.agent.agentName,
839
+ agentEmoji: data.agent.agentEmoji,
840
+ });
841
+ safeWrite(join(OPENCLAW_DIR, 'openclaw.json'), JSON.stringify(openclawConfig, null, 2) + '\n');
842
+ success('openclaw.json');
843
+
844
+ // 2. SOUL.md
845
+ const personality = data.agent.personalityKey === 'custom'
846
+ ? {
847
+ tagline: 'Your custom AI assistant.',
848
+ traits: `1. **Adaptable** — You match the style your human needs.\n2. **Attentive** — ${data.agent.customPersonality}\n3. **Reliable** — You follow through on everything.`,
849
+ style: `- Adapt to the user's communication style\n- Be responsive and thorough\n- Ask questions when unsure\n- Keep responses focused and actionable\n- Customize your approach based on feedback`,
850
+ }
851
+ : PERSONALITIES[data.agent.personalityKey];
852
+
853
+ const soulContent = renderSimple(loadTemplate('soul.md.tmpl'), {
854
+ AGENT_NAME: data.agent.agentName,
855
+ TAGLINE: personality.tagline,
856
+ USER_NAME: data.personal.name,
857
+ USER_ROLE: data.personal.role,
858
+ USER_COMPANY: data.personal.company,
859
+ PERSONALITY_TRAITS: personality.traits,
860
+ WORKING_STYLE: personality.style,
861
+ });
862
+ safeWrite(join(WORKSPACE_DIR, 'SOUL.md'), soulContent);
863
+ success('SOUL.md');
864
+
865
+ // 3. USER.md
866
+ const userContent = renderSimple(loadTemplate('user.md.tmpl'), {
867
+ USER_NAME: data.personal.name,
868
+ USER_ROLE: data.personal.role,
869
+ USER_COMPANY: data.personal.company,
870
+ USER_TIMEZONE: data.personal.timezone,
871
+ });
872
+ safeWrite(join(WORKSPACE_DIR, 'USER.md'), userContent);
873
+ success('USER.md');
874
+
875
+ // 4. AGENTS.md
876
+ const agentsContent = loadTemplate('agents.md.tmpl');
877
+ safeWrite(join(WORKSPACE_DIR, 'AGENTS.md'), agentsContent);
878
+ success('AGENTS.md');
879
+
880
+ // 5. HEARTBEAT.md
881
+ const heartbeatSections = [...data.useCases.useCaseKeys];
882
+ if (data.integrations.calendar) heartbeatSections.push('CALENDAR');
883
+ const heartbeatContent = renderHeartbeat(loadTemplate('heartbeat.md.tmpl'), heartbeatSections);
884
+ safeWrite(join(WORKSPACE_DIR, 'HEARTBEAT.md'), heartbeatContent);
885
+ success('HEARTBEAT.md');
886
+
887
+ // 6. Agent directory
888
+ const agentDir = join(WORKSPACE_DIR, 'agents', data.agent.agentName.toLowerCase().replace(/\s+/g, '-'));
889
+ const agentReadme = `# ${data.agent.agentName}\n\nPrimary AI assistant for ${data.personal.name}.\n\n- **Personality:** ${data.agent.personalityKey}\n- **Created:** ${new Date().toISOString().split('T')[0]}\n`;
890
+ safeWrite(join(agentDir, 'README.md'), agentReadme);
891
+ success(`agents/${data.agent.agentName.toLowerCase().replace(/\s+/g, '-')}/`);
892
+
893
+ // 7. Memory and tasks directories
894
+ safeWrite(join(WORKSPACE_DIR, 'memory', '.gitkeep'), '');
895
+ safeWrite(join(WORKSPACE_DIR, 'TASKS.md'), `# Tasks\n\n*No tasks yet. ${data.agent.agentName} will help you manage these.*\n`);
896
+ success('TASKS.md');
897
+ success('memory/');
898
+ }
899
+
900
+ // ─── Phase 7: Welcome Screen ────────────────────────────────────────────────
901
+
902
+ function phaseWelcome(data) {
903
+ const line = '═'.repeat(56);
904
+ console.log(`\n${c.green}${line}${c.reset}`);
905
+ console.log(`${c.bold}${c.green} ✅ Unified Mission Control is ready!${c.reset}`);
906
+ console.log(`${c.green}${line}${c.reset}\n`);
907
+
908
+ console.log(` ${c.bold}🌐 Open:${c.reset} http://localhost:5173`);
909
+ console.log(` ${c.bold}🤖 AI:${c.reset} ${data.agent.agentEmoji} ${data.agent.agentName} is online`);
910
+
911
+ if (data.integrations.gmail) console.log(` ${c.bold}📧 Gmail:${c.reset} connected (read-only)`);
912
+ if (data.integrations.calendar) console.log(` ${c.bold}📅 Calendar:${c.reset} connected (read-only)`);
913
+ if (data.integrations.slack) console.log(` ${c.bold}💬 Slack:${c.reset} connected (read-only)`);
914
+ if (data.integrations.github) console.log(` ${c.bold}🐙 GitHub:${c.reset} connected`);
915
+
916
+ console.log(`\n ${c.bold}Quick tips:${c.reset}`);
917
+ bullet('Click the chat panel to talk to ' + data.agent.agentName);
918
+ bullet('The sidebar has all your tools');
919
+ bullet('Settings panel to change permissions');
920
+
921
+ if (DRY_RUN) {
922
+ console.log(`\n ${c.yellow}${c.bold}[DRY RUN]${c.reset}${c.yellow} No files were written.${c.reset}`);
923
+ }
924
+
925
+ console.log(`\n ${c.dim}Need help? https://github.com/erenes1667/unified-mc${c.reset}`);
926
+ console.log(`\n${c.green}${line}${c.reset}\n`);
927
+ }
928
+
929
+ // ─── Main ─────────────────────────────────────────────────────────────────────
930
+
931
+ async function main() {
932
+ if (DRY_RUN) {
933
+ console.log(`\n${c.yellow}${c.bold} 🧪 DRY RUN MODE${c.reset}${c.yellow} — No files will be written.${c.reset}\n`);
934
+ }
935
+
936
+ initReadline();
937
+
938
+ try {
939
+ const personal = await phasePersonalInfo();
940
+ const ai = await phaseAISetup();
941
+ const agent = await phaseAgentSetup();
942
+ const integrations = await phaseIntegrations();
943
+ const useCases = await phaseUseCases();
944
+
945
+ const data = { personal, ai, agent, integrations, useCases };
946
+
947
+ phaseGenerate(data);
948
+ phaseWelcome(data);
949
+ } catch (err) {
950
+ if (err.code === 'ERR_USE_AFTER_CLOSE' || err.message?.includes('readline was closed')) {
951
+ // User closed stdin (e.g., piped input ended)
952
+ console.log(`\n${c.yellow} Input ended. Run interactively for the full wizard.${c.reset}\n`);
953
+ } else {
954
+ error(`Something went wrong: ${err.message}`);
955
+ if (!DRY_RUN) {
956
+ info('Your existing configuration was not modified.');
957
+ info('Try running the wizard again, or check the error above.');
958
+ }
959
+ process.exit(1);
960
+ }
961
+ } finally {
962
+ rl?.close();
963
+ }
964
+ }
965
+
966
+ main();