mixdog 0.7.7 → 0.7.11

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/setup/wizard.mjs DELETED
@@ -1,696 +0,0 @@
1
- /**
2
- * Interactive terminal setup wizard for `npx mixdog` / mixdog-install.
3
- * Runs after plugin registration when stdin is a TTY and CI is unset.
4
- *
5
- * Callers must set process.env.CLAUDE_PLUGIN_DATA before importing this
6
- * module (install.mjs does that) so src/shared/config.mjs can resolve paths.
7
- */
8
- import { spawnSync } from 'node:child_process';
9
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
10
- import { join, dirname, basename } from 'node:path';
11
- import { fileURLToPath } from 'node:url';
12
- import { defaultPluginDataDir } from './install.mjs';
13
- import {
14
- mergeAgentConfig,
15
- mergeMemoryConfig,
16
- mergeConfig,
17
- mergeSearchConfig,
18
- } from './config-merge.mjs';
19
- import { DEFAULT_MODELS } from '../src/search/lib/config.mjs';
20
- import {
21
- select,
22
- multiselect,
23
- confirm,
24
- text,
25
- password,
26
- createProgressBar,
27
- createSpinner,
28
- } from './tui.mjs';
29
-
30
- let _linuxSecretsCapable;
31
- const KEYTAR_SERVICE = 'mixdog';
32
- const KEYTAR_PROBE_TIMEOUT_MS = 8000;
33
-
34
- const __dirname = dirname(fileURLToPath(import.meta.url));
35
- const REPO_ROOT = join(__dirname, '..');
36
- const DEFAULT_USER_WORKFLOW = JSON.parse(
37
- readFileSync(join(REPO_ROOT, 'defaults', 'user-workflow.json'), 'utf8'),
38
- );
39
-
40
- const AG_API_PROVIDERS = [
41
- { id: 'openai', name: 'OpenAI', env: 'OPENAI_API_KEY' },
42
- { id: 'anthropic', name: 'Anthropic', env: 'ANTHROPIC_API_KEY' },
43
- { id: 'gemini', name: 'Gemini', env: 'GEMINI_API_KEY' },
44
- { id: 'deepseek', name: 'DeepSeek', env: 'DEEPSEEK_API_KEY' },
45
- { id: 'xai', name: 'xAI', env: 'XAI_API_KEY' },
46
- { id: 'nvidia', name: 'NVIDIA', env: 'NVIDIA_API_KEY' },
47
- ];
48
-
49
- const WORKFLOW_ROLES = ['worker', 'reviewer', 'debugger', 'tester'];
50
-
51
- /** Raw SERP API keys (setup.html SR_KEY_PROVIDERS) — independent of active provider. */
52
- const SEARCH_RAW_KEY_PROVIDERS = [
53
- { id: 'firecrawl', name: 'Firecrawl' },
54
- { id: 'tavily', name: 'Tavily' },
55
- { id: 'exa', name: 'Exa' },
56
- ];
57
- const SEARCH_OAUTH_PROVIDERS = new Set(['anthropic-oauth', 'openai-oauth', 'grok-oauth']);
58
- const OPENAI_SEARCH_EFFORT_VALUES = new Set(['low', 'medium', 'high']);
59
- const SEARCH_OAUTH_ALIASES = Object.freeze({
60
- 'anthropic-oauth': 'anthropic-oauth',
61
- anthropic: 'anthropic-oauth',
62
- 'openai-oauth': 'openai-oauth',
63
- openai: 'openai-oauth',
64
- 'grok-oauth': 'grok-oauth',
65
- grok: 'grok-oauth',
66
- });
67
- function pluginDataDir() {
68
- const dir = process.env.CLAUDE_PLUGIN_DATA;
69
- if (dir && typeof dir === 'string' && String(dir).trim()) {
70
- return String(dir).trim();
71
- }
72
- return defaultPluginDataDir();
73
- }
74
-
75
- /** One-time Linux preflight: optional keytar must load before any secret prompt. */
76
- export function resetLinuxSecretsCapableCache() {
77
- _linuxSecretsCapable = undefined;
78
- }
79
-
80
- function runLinuxKeytarOperationalProbe() {
81
- const script = [
82
- 'try {',
83
- 'const keytar = require("keytar");',
84
- 'if (process.env.MIXDOG_KEYTAR_PROBE_INJECT_FAIL === "1") process.exit(3);',
85
- `keytar.findCredentials(${JSON.stringify(KEYTAR_SERVICE)})`,
86
- ' .then(() => process.exit(0))',
87
- ' .catch((e) => { process.stderr.write(String(e && e.message ? e.message : e)); process.exit(1); });',
88
- '} catch (e) { process.stderr.write(String(e && e.message ? e.message : e)); process.exit(2); }',
89
- ].join(' ');
90
- const r = spawnSync(process.execPath, ['-e', script], {
91
- env: { ...process.env },
92
- encoding: 'utf8',
93
- timeout: KEYTAR_PROBE_TIMEOUT_MS,
94
- windowsHide: true,
95
- stdio: ['ignore', 'ignore', 'pipe'],
96
- });
97
- if (r.error) return false;
98
- return r.status === 0;
99
- }
100
-
101
- /**
102
- * @param {{ treatAsLinux?: boolean }} [probeOptions] — .scratch harness only (operational probe).
103
- */
104
- export function probeLinuxSecretsCapable(probeOptions = null) {
105
- const isLinux = probeOptions?.treatAsLinux === true || process.platform === 'linux';
106
- if (!isLinux) return true;
107
- if (_linuxSecretsCapable !== undefined) return _linuxSecretsCapable;
108
- _linuxSecretsCapable = runLinuxKeytarOperationalProbe();
109
- return _linuxSecretsCapable;
110
- }
111
-
112
- export function linuxKeychainUnavailableMessage() {
113
- return [
114
- '',
115
- '⚠ Linux keychain unavailable (optional `keytar` not installed or libsecret missing).',
116
- ' Secret prompts are skipped; non-secret setup continues. Install keytar later, then use the Mixdog UI:',
117
- ' Debian/Ubuntu: sudo apt install libsecret-1-dev then: npm install keytar',
118
- ' Fedora/RHEL: sudo dnf install libsecret-devel then: npm install keytar',
119
- ' Arch: sudo pacman -S libsecret then: npm install keytar',
120
- '',
121
- ].join('\n');
122
- }
123
-
124
- function sanitizeName(n) {
125
- if (!n || typeof n !== 'string') return null;
126
- if (n !== basename(n)) return null;
127
- if (n.includes('..') || n.startsWith('.')) return null;
128
- return n;
129
- }
130
-
131
- function isSkippableAnswer(raw) {
132
- return raw === undefined || raw === null || String(raw).trim() === '';
133
- }
134
-
135
- function defaultIo() {
136
- const interactive = !!(process.stdin.isTTY && !process.env.CI);
137
- if (!interactive) {
138
- return {
139
- interactive: false,
140
- ask: async () => '',
141
- askSecret: async () => '',
142
- say: (line) => { if (line) console.log(line); },
143
- };
144
- }
145
- return {
146
- interactive: true,
147
- say: (line) => { if (line) console.log(line); },
148
- close: () => {},
149
- };
150
- }
151
-
152
- async function loadConfigModules() {
153
- const { ensureDataSeeds } = await import('../src/shared/seed.mjs');
154
- const { readSection, updateSection } = await import('../src/shared/config.mjs');
155
- const { DEFAULT_PRESETS, DEFAULT_MAINTENANCE } = await import('../src/agent/orchestrator/config.mjs');
156
- const dataDir = pluginDataDir();
157
- mkdirSync(dataDir, { recursive: true });
158
- ensureDataSeeds(dataDir);
159
- return { readSection, updateSection, DEFAULT_PRESETS, DEFAULT_MAINTENANCE, dataDir };
160
- }
161
-
162
- function readUserWorkflow(dataDir) {
163
- const path = join(dataDir, 'user-workflow.json');
164
- if (!existsSync(path)) return structuredClone(DEFAULT_USER_WORKFLOW);
165
- try {
166
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
167
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
168
- throw new SyntaxError('user-workflow root must be a JSON object');
169
- }
170
- return parsed;
171
- } catch (parseErr) {
172
- const corrupt = `${path}.corrupt-${Date.now()}`;
173
- try {
174
- if (existsSync(path)) renameSync(path, corrupt);
175
- } catch {}
176
- process.stderr.write(
177
- `[wizard] user-workflow.json is malformed (${parseErr.message}). Renamed to ${corrupt}. Fix or delete before re-running setup.\n`,
178
- );
179
- throw new Error(
180
- `[wizard] user-workflow.json is malformed (${parseErr.message}); refusing role mapping that would overwrite user data`,
181
- );
182
- }
183
- }
184
-
185
- function writeUserWorkflow(dataDir, data) {
186
- const path = join(dataDir, 'user-workflow.json');
187
- const roles = Array.isArray(data?.roles) ? data.roles.slice() : [];
188
- if (!roles.some((r) => r?.name === 'worker')) {
189
- const seedWorker = DEFAULT_USER_WORKFLOW.roles.find((r) => r?.name === 'worker');
190
- if (seedWorker) roles.unshift({ ...seedWorker });
191
- }
192
- const sanitizedRoles = roles.map((r) => {
193
- if (!r || typeof r !== 'object') return r;
194
- const name = sanitizeName(r.name);
195
- if (name == null) throw new Error(`invalid role name: ${r.name}`);
196
- return { ...r, name };
197
- });
198
- mkdirSync(dirname(path), { recursive: true });
199
- const tmp = `${path}.tmp`;
200
- writeFileSync(tmp, JSON.stringify({ ...data, roles: sanitizedRoles }, null, 2) + '\n', 'utf8');
201
- renameSync(tmp, path);
202
- }
203
-
204
- function presetIdsFromAgent(agentSection) {
205
- const presets = Array.isArray(agentSection?.presets) ? agentSection.presets : [];
206
- return presets.map((p) => p.id || p.name).filter(Boolean);
207
- }
208
-
209
- export async function stepDiscordToken(io, { updateSection, readSection, secretsCapable = true }) {
210
- const { hasStoredSecret, SECRET_ACCOUNTS, getDiscordToken } = await import('../src/shared/config.mjs');
211
- io.say('\n── Step 2/9: Discord ──');
212
- io.say('Bot token (keychain), application ID, and optional main channel.');
213
-
214
- let hadStoredToken = false;
215
- let token = '';
216
- let enteredToken = false;
217
- if (!secretsCapable) {
218
- io.say('• Discord bot token: skipped (Linux keychain unavailable).');
219
- } else {
220
- hadStoredToken = hasStoredSecret(SECRET_ACCOUNTS.discordToken);
221
- const tokenPrompt = hadStoredToken
222
- ? 'Discord bot token (stored — leave empty to keep)'
223
- : 'Discord bot token (leave empty to skip whole step)';
224
- token = (await password(tokenPrompt)).trim();
225
- enteredToken = !isSkippableAnswer(token);
226
- if (!enteredToken && !hadStoredToken) {
227
- io.say('• Skipped Discord setup.');
228
- return false;
229
- }
230
- }
231
-
232
- const channels = readSection('channels') || {};
233
- const curDiscord = channels.discord && typeof channels.discord === 'object' ? channels.discord : {};
234
- const curAppId = String(curDiscord.applicationId || '').trim();
235
- const appIdBase = 'Application ID';
236
- const appIdRaw = await text(appIdBase, {
237
- placeholder: curAppId ? `${curAppId} (Enter to keep)` : '',
238
- initial: '',
239
- });
240
- const appIdToSet = isSkippableAnswer(appIdRaw) ? '' : String(appIdRaw).trim();
241
- const chIdRaw = await text('Main channel ID', { placeholder: 'Enter to skip channel', initial: '' });
242
- const channelId = isSkippableAnswer(chIdRaw) ? '' : String(chIdRaw).trim();
243
- const channelName = 'main';
244
- const mode = 'interactive';
245
- const secrets = {};
246
- updateSection('channels', (current) => {
247
- const payload = {};
248
- const discord = {};
249
- if (enteredToken) discord.token = token;
250
- if (appIdToSet) discord.applicationId = appIdToSet;
251
- if (Object.keys(discord).length > 0) payload.discord = discord;
252
- if (channelId) {
253
- const existingCfg = current.channelsConfig && typeof current.channelsConfig === 'object'
254
- ? { ...current.channelsConfig }
255
- : {};
256
- existingCfg[channelName] = { channelId, mode };
257
- payload.channelsConfig = existingCfg;
258
- payload.mainChannel = channelName;
259
- }
260
- return mergeConfig(current, payload, secrets);
261
- });
262
- if (enteredToken) io.say('• Discord token saved to keychain.');
263
- if (appIdToSet) io.say('• Application ID saved.');
264
- if (channelId) io.say(`• Main channel "${channelName}" configured (${mode}).`);
265
- return enteredToken || hadStoredToken || !!getDiscordToken();
266
- }
267
-
268
- function formatVoiceProgress(p) {
269
- const phase = p?.phase || 'download';
270
- const downloaded = Number(p?.downloaded) || 0;
271
- const total = Number(p?.total) || 0;
272
- if (total > 0) {
273
- const pct = Math.floor((downloaded / total) * 100);
274
- const mb = (n) => `${(n / (1024 * 1024)).toFixed(1)} MB`;
275
- return `${phase} · ${mb(downloaded)} / ${mb(total)} (${pct}%)`;
276
- }
277
- const mb = (downloaded / (1024 * 1024)).toFixed(1);
278
- return `${phase} · received ${mb} MB`;
279
- }
280
-
281
- async function installVoiceRuntime(dataDir) {
282
- const {
283
- ensureWhisperRuntime,
284
- ensureWhisperModel,
285
- ensureFfmpegRuntime,
286
- } = await import('../src/channels/lib/voice-runtime-fetcher.mjs');
287
- let spinner = createSpinner('Downloading voice runtime…');
288
- let bar = null;
289
- const onProgress = (p) => {
290
- const line = formatVoiceProgress(p);
291
- if (!line) return;
292
- const downloaded = Number(p?.downloaded) || 0;
293
- const total = Number(p?.total) || 0;
294
- if (total > 0) {
295
- if (!bar) {
296
- spinner.stop('', true);
297
- spinner = null;
298
- bar = createProgressBar('Voice download', { total });
299
- }
300
- bar.update(downloaded, total);
301
- } else if (spinner) {
302
- spinner.update(line);
303
- }
304
- };
305
- await ensureWhisperRuntime(dataDir, onProgress);
306
- const model = await ensureWhisperModel(dataDir, onProgress);
307
- await ensureFfmpegRuntime(dataDir, onProgress);
308
- if (bar) bar.done('complete');
309
- else if (spinner) spinner.stop('complete', true);
310
- return model?.modelId || 'large-v3-turbo';
311
- }
312
-
313
- /** Mirrors setup.html channels save: `voice` via POST /config → mergeConfig. */
314
- async function stepVoiceTranscription(io, ctx, discordTokenSaved) {
315
- if (!discordTokenSaved) return;
316
- io.say('\n── Step 2a/9: Voice transcription (음성 전사) ──');
317
- io.say('Install local Speech-to-text (whisper.cpp) for Discord voice messages.');
318
- const enable = await confirm('Enable voice transcription?', { initial: false });
319
- if (!enable) {
320
- io.say('• Skipped voice transcription.');
321
- return;
322
- }
323
- const { updateSection, dataDir } = ctx;
324
- try {
325
- const modelId = await installVoiceRuntime(dataDir);
326
- const voice = { language: 'auto', model: modelId };
327
- updateSection('channels', (current) => mergeConfig(current, { voice }, {}));
328
- io.say('• Voice transcription runtime installed and channels voice config saved.');
329
- } catch (err) {
330
- io.say(`• Voice install failed: ${err?.message || err}`);
331
- throw err;
332
- }
333
- }
334
-
335
- async function stepAddressForm(io, { updateSection, readSection }) {
336
- io.say('\n── Step 1/9: Address form (호칭) ──');
337
- const memory = readSection('memory');
338
- const curTitle = memory?.user?.title || '';
339
- const curName = memory?.user?.name || '';
340
- const titleRaw = await text('How should Mixdog address you? user.title', {
341
- placeholder: curTitle || 'Enter to skip',
342
- initial: '',
343
- });
344
- const nameRaw = await text('Your display name? user.name', {
345
- placeholder: curName || 'Enter to skip',
346
- initial: '',
347
- });
348
- const title = isSkippableAnswer(titleRaw) ? '' : String(titleRaw).trim();
349
- const name = isSkippableAnswer(nameRaw) ? '' : String(nameRaw).trim();
350
- if (!title && !name) {
351
- io.say('• Skipped address form.');
352
- return;
353
- }
354
- const user = {};
355
- if (title) user.title = title;
356
- if (name) user.name = name;
357
- const secrets = {};
358
- updateSection('memory', (current) => mergeMemoryConfig(current, { user }, secrets));
359
- io.say('• Saved memory.user (title/name).');
360
- }
361
-
362
- export async function stepWebhookReceiver(io, { updateSection, readSection, secretsCapable = true }) {
363
- io.say('\n── Step 3/9: Inbound webhooks (ngrok receiver) ──');
364
- io.say('Global webhook tunnel for inbound HTTP (channels.webhook). Per-endpoint registration is configured later in the UI.');
365
- const enableWebhooks = await confirm('Enable inbound webhooks?', { initial: false });
366
- if (!enableWebhooks) {
367
- io.say('• Skipped webhook setup.');
368
- return;
369
- }
370
- const { hasStoredSecret, SECRET_ACCOUNTS } = await import('../src/shared/config.mjs');
371
- const channels = readSection('channels') || {};
372
- const curWebhook = channels.webhook && typeof channels.webhook === 'object' ? channels.webhook : {};
373
- const curDomain = String(curWebhook.domain || curWebhook.ngrokDomain || '').trim();
374
- const domainBase =
375
- 'Domain (ngrok, e.g. your-name.ngrok-free.dev — get it at dashboard.ngrok.com/domains)';
376
- const domainRaw = await text(domainBase, {
377
- placeholder: curDomain ? `${curDomain} (Enter to keep)` : '',
378
- initial: '',
379
- });
380
- const webhook = { enabled: true };
381
- if (!isSkippableAnswer(domainRaw)) {
382
- webhook.domain = String(domainRaw).trim();
383
- }
384
- if (secretsCapable) {
385
- const authPrompt = hasStoredSecret(SECRET_ACCOUNTS.webhookAuth)
386
- ? 'ngrok Auth Token (stored — leave empty to keep)'
387
- : 'ngrok Auth Token';
388
- webhook.authtoken = (await password(authPrompt)).trim();
389
- } else {
390
- io.say('• ngrok Auth Token: skipped (Linux keychain unavailable).');
391
- }
392
- const secrets = {};
393
- updateSection('channels', (current) => mergeConfig(current, { webhook }, secrets));
394
- io.say(secretsCapable
395
- ? '• Inbound webhook receiver saved (channels.webhook enabled/domain; authtoken in keychain).'
396
- : '• Inbound webhook receiver saved (channels.webhook enabled/domain; authtoken not collected).');
397
- }
398
-
399
- async function stepProviderKeys(io, { updateSection, secretsCapable = true }) {
400
- io.say('\n── Step 4/9: Provider API keys ──');
401
- if (!secretsCapable) {
402
- io.say('• Skipped provider API keys (Linux keychain unavailable).');
403
- return;
404
- }
405
- io.say('Optional API keys (hidden). Pick providers to configure.');
406
- const providerOpts = AG_API_PROVIDERS.map((p) => ({
407
- value: p.id,
408
- label: p.name,
409
- hint: p.env,
410
- }));
411
- const selectedIds = await multiselect('Which provider API keys to set?', providerOpts, { min: 0 });
412
- const providers = {};
413
- for (const p of AG_API_PROVIDERS.filter((x) => selectedIds.includes(x.id))) {
414
- const key = (await password(`${p.name} API key (${p.env})`)).trim();
415
- if (!isSkippableAnswer(key)) {
416
- providers[p.id] = { apiKey: key, enabled: true };
417
- }
418
- }
419
- if (Object.keys(providers).length === 0) {
420
- io.say('• Skipped provider keys.');
421
- return;
422
- }
423
- const secrets = {};
424
- updateSection('agent', (current) => mergeAgentConfig(current, { providers }, secrets));
425
- io.say(`• Saved ${Object.keys(providers).length} provider key(s) to keychain.`);
426
- }
427
-
428
- async function stepPresets(io, { readSection, updateSection, DEFAULT_PRESETS }) {
429
- io.say('\n── Step 5/9: Agent presets ──');
430
- const agent = readSection('agent');
431
- const existing = presetIdsFromAgent(agent);
432
- if (existing.length > 0) {
433
- io.say(`Current presets: ${existing.join(', ')}`);
434
- }
435
- const installDefaults = await confirm('Install default Mixdog presets?', { initial: false });
436
- if (!installDefaults) {
437
- io.say('• Kept existing presets.');
438
- return;
439
- }
440
- const list = DEFAULT_PRESETS.map((p) => ({ ...p }));
441
- updateSection('agent', (current) => {
442
- const next = { ...current, presets: list };
443
- const validKeys = list.map((p) => p.id).filter(Boolean);
444
- if (!next.default || !validKeys.includes(next.default)) next.default = validKeys[0] || null;
445
- return next;
446
- });
447
- io.say(`• Installed ${list.length} default presets.`);
448
- }
449
-
450
- async function stepRolePresetMapping(io, { readSection, dataDir }) {
451
- io.say('\n── Step 6/9: Role → preset mapping ──');
452
- const agent = readSection('agent');
453
- const presetIds = presetIdsFromAgent(agent);
454
- if (presetIds.length === 0) {
455
- io.say('No presets on disk — run step 5 first or configure presets in the Mixdog UI later.');
456
- return;
457
- }
458
- io.say(`Available presets: ${presetIds.join(', ')}`);
459
- const wf = readUserWorkflow(dataDir);
460
- const roles = Array.isArray(wf.roles) ? wf.roles : [];
461
- const byName = new Map(roles.map((r) => [r.name, r]));
462
- for (const roleName of WORKFLOW_ROLES) {
463
- const cur = byName.get(roleName)?.preset || DEFAULT_USER_WORKFLOW.roles.find((r) => r.name === roleName)?.preset || '';
464
- const options = [
465
- { value: '__keep__', label: `Keep current${cur ? ` (${cur})` : ''}` },
466
- ...presetIds.map((id) => ({ value: id, label: id })),
467
- ];
468
- const chosen = await select(`Preset for role "${roleName}"`, options, {
469
- initial: cur && presetIds.includes(cur) ? cur : '__keep__',
470
- });
471
- if (chosen === '__keep__') continue;
472
- const preset = String(chosen).trim();
473
- if (!presetIds.includes(preset)) {
474
- io.say(` ! Unknown preset "${preset}" — left "${roleName}" unchanged.`);
475
- continue;
476
- }
477
- const entry = byName.get(roleName) || { name: roleName, permission: 'full' };
478
- entry.preset = preset;
479
- byName.set(roleName, entry);
480
- }
481
- const mergedRoles = WORKFLOW_ROLES.map((name) => byName.get(name)).filter(Boolean);
482
- for (const r of roles) {
483
- if (!WORKFLOW_ROLES.includes(r.name) && !mergedRoles.some((x) => x.name === r.name)) {
484
- mergedRoles.push(r);
485
- }
486
- }
487
- writeUserWorkflow(dataDir, { ...wf, roles: mergedRoles });
488
- io.say('• Role → preset mapping saved to user-workflow.json.');
489
- }
490
-
491
- function resolveSearchBackendInput(raw) {
492
- if (isSkippableAnswer(raw)) return null;
493
- const key = String(raw).trim().toLowerCase();
494
- return SEARCH_OAUTH_ALIASES[key] || null;
495
- }
496
-
497
- /** Mirrors POST /search/config → mergeSearchConfig. */
498
- export async function stepSearchBackend(io, { updateSection, readSection, secretsCapable = true }) {
499
- io.say('\n── Step 7/9: Search backend ──');
500
- io.say('Active provider: anthropic-oauth | openai-oauth | grok-oauth (OAuth — uses Agent credentials).');
501
- const { hasStoredSecret, SECRET_ACCOUNTS, getSearchApiKey } = await import('../src/shared/config.mjs');
502
- const search = readSection('search') || {};
503
- const curProvider = String(search.provider || 'anthropic-oauth').trim() || 'anthropic-oauth';
504
- const providerOptions = [
505
- { value: 'anthropic-oauth', label: 'anthropic-oauth', hint: 'Claude OAuth' },
506
- { value: 'openai-oauth', label: 'openai-oauth', hint: 'OpenAI OAuth' },
507
- { value: 'grok-oauth', label: 'grok-oauth', hint: 'xAI Grok OAuth' },
508
- ];
509
- const backendRaw = await select('Search provider', providerOptions, {
510
- initial: SEARCH_OAUTH_PROVIDERS.has(curProvider) ? curProvider : 'anthropic-oauth',
511
- });
512
- let provider = curProvider;
513
- if (!isSkippableAnswer(backendRaw)) {
514
- const resolved = resolveSearchBackendInput(backendRaw) || String(backendRaw).trim();
515
- if (!SEARCH_OAUTH_PROVIDERS.has(resolved)) {
516
- io.say(` ! Provider "${resolved}" is not a supported OAuth backend — keeping ${curProvider}.`);
517
- } else {
518
- provider = resolved;
519
- }
520
- }
521
-
522
- const payload = {};
523
- if (provider !== curProvider) payload.provider = provider;
524
-
525
- const searchProviders = {};
526
- if (secretsCapable) {
527
- for (const p of SEARCH_RAW_KEY_PROVIDERS) {
528
- const hadKey = hasStoredSecret(SECRET_ACCOUNTS.searchApiKey(p.id));
529
- const keyPrompt = hadKey
530
- ? `${p.name} API key (stored — leave empty to keep)`
531
- : `${p.name} API key (leave empty to skip)`;
532
- const key = (await password(keyPrompt)).trim();
533
- if (!isSkippableAnswer(key)) {
534
- searchProviders[p.id] = key;
535
- }
536
- }
537
- } else {
538
- io.say('• Search provider API keys: skipped (Linux keychain unavailable).');
539
- }
540
- if (Object.keys(searchProviders).length) payload.searchProviders = searchProviders;
541
-
542
- if (provider === 'openai-oauth') {
543
- const curModel = (search.models && search.models.openai)
544
- || DEFAULT_MODELS.openai
545
- || '';
546
- const modelRaw = await text('OpenAI model', {
547
- placeholder: curModel ? `${curModel} (Enter to keep)` : 'Enter to keep',
548
- initial: '',
549
- });
550
- if (!isSkippableAnswer(modelRaw)) {
551
- const model = String(modelRaw).trim();
552
- if (model) payload.models = { ...(payload.models || {}), openai: model };
553
- }
554
- const curEffort = String(search.modelOptions?.openai?.effort || 'medium').trim() || 'medium';
555
- const effortRaw = await select('OpenAI effort', [
556
- { value: '__keep__', label: `Keep current (${curEffort})` },
557
- { value: 'low', label: 'low' },
558
- { value: 'medium', label: 'medium' },
559
- { value: 'high', label: 'high' },
560
- ], { initial: '__keep__' });
561
- if (!isSkippableAnswer(effortRaw) && effortRaw !== '__keep__') {
562
- const effort = String(effortRaw).trim().toLowerCase();
563
- if (!OPENAI_SEARCH_EFFORT_VALUES.has(effort)) {
564
- io.say(` ! Unknown effort "${effortRaw.trim()}" — keeping ${curEffort}.`);
565
- } else {
566
- const openaiOpts = { ...(search.modelOptions?.openai || {}), effort };
567
- payload.modelOptions = { ...(payload.modelOptions || {}), openai: openaiOpts };
568
- }
569
- }
570
- const curFast = !!search.modelOptions?.openai?.fast;
571
- const fastKeep = await confirm(`OpenAI Fast mode (current: ${curFast ? 'on' : 'off'}) — enable?`, {
572
- initial: curFast,
573
- });
574
- const fastParsed = fastKeep === curFast ? null : fastKeep;
575
- if (fastParsed !== null) {
576
- const base = payload.modelOptions?.openai || search.modelOptions?.openai || {};
577
- const openaiOpts = { ...base };
578
- if (fastParsed) openaiOpts.fast = true;
579
- else delete openaiOpts.fast;
580
- // Write even an empty object: mergeSearchConfig treats an empty per-family
581
- // entry as "clear this family", which is how an explicit Fast=n drops a
582
- // lone fast flag (no effort left to keep the object non-empty).
583
- payload.modelOptions = { ...(payload.modelOptions || {}), openai: openaiOpts };
584
- }
585
- } else if (provider === 'grok-oauth') {
586
- const curModel = (search.models && search.models.xai)
587
- || DEFAULT_MODELS.xai
588
- || '';
589
- const modelRaw = await text('xAI model', {
590
- placeholder: curModel ? `${curModel} (Enter to keep)` : 'Enter to keep',
591
- initial: '',
592
- });
593
- if (!isSkippableAnswer(modelRaw)) {
594
- const model = String(modelRaw).trim();
595
- if (model) payload.models = { ...(payload.models || {}), xai: model };
596
- }
597
- }
598
-
599
- const secrets = {};
600
- updateSection('search', (current) => mergeSearchConfig(current, payload, secrets));
601
- const after = readSection('search') || {};
602
- const savedProvider = after.provider || curProvider;
603
- io.say(`• Search provider: ${savedProvider}.`);
604
- for (const p of SEARCH_RAW_KEY_PROVIDERS) {
605
- io.say(`• ${p.name} API key: ${getSearchApiKey(p.id) ? 'stored' : 'not set'}.`);
606
- }
607
- if (savedProvider === 'openai-oauth' && after.models?.openai) {
608
- io.say(`• OpenAI model: ${after.models.openai}.`);
609
- }
610
- if (savedProvider === 'grok-oauth' && after.models?.xai) {
611
- io.say(`• xAI model: ${after.models.xai}.`);
612
- }
613
- if (savedProvider === 'openai-oauth' && after.modelOptions?.openai?.effort) {
614
- io.say(`• OpenAI effort: ${after.modelOptions.openai.effort}.`);
615
- }
616
- if (savedProvider === 'openai-oauth' && after.modelOptions?.openai?.fast) {
617
- io.say('• OpenAI Fast mode: on.');
618
- }
619
- }
620
-
621
- /** Mirrors POST /agent/maintenance for the explore slot. */
622
- export async function stepExplorerPreset(io, { readSection, updateSection, DEFAULT_PRESETS, DEFAULT_MAINTENANCE }) {
623
- io.say('\n── Step 8/9: Explorer model (explore tool) ──');
624
- const agent = readSection('agent') || {};
625
- const presetIds = presetIdsFromAgent(agent);
626
- const validIds = new Set([
627
- ...presetIds,
628
- ...DEFAULT_PRESETS.map((p) => p.id).filter(Boolean),
629
- ]);
630
- const curExplore = String(agent.maintenance?.explore || DEFAULT_MAINTENANCE.explore || 'haiku').trim() || 'haiku';
631
- if (validIds.size > 0) {
632
- io.say(`Available presets: ${[...validIds].join(', ')}`);
633
- }
634
- const exploreOptions = [
635
- { value: '__keep__', label: `Keep current (${curExplore})` },
636
- ...[...validIds].map((id) => ({ value: id, label: id })),
637
- ];
638
- const raw = await select('Preset for explorer (explore tool)', exploreOptions, {
639
- initial: validIds.has(curExplore) ? curExplore : '__keep__',
640
- });
641
- if (raw === '__keep__' || isSkippableAnswer(raw)) {
642
- io.say(`• Explorer preset unchanged (${curExplore}).`);
643
- return;
644
- }
645
- const preset = String(raw).trim();
646
- if (!validIds.has(preset)) {
647
- io.say(` ! Unknown preset "${preset}" — keeping ${curExplore}.`);
648
- return;
649
- }
650
- updateSection('agent', (current) => ({
651
- ...current,
652
- maintenance: { ...(current.maintenance || {}), explore: preset },
653
- }));
654
- io.say(`• Explorer maintenance preset: ${preset}.`);
655
- }
656
-
657
- /**
658
- * @param {object} [ioOverride]
659
- * @param {boolean} [ioOverride.interactive]
660
- * @param {(prompt:string)=>Promise<string>} [ioOverride.ask]
661
- * @param {(prompt:string)=>Promise<string>} [ioOverride.askSecret]
662
- * @param {(line:string)=>void} [ioOverride.say]
663
- * @param {object} [options]
664
- * @param {boolean} [options.secretsCapable] — override Linux keytar preflight (tests).
665
- */
666
- export async function runSetupWizard(ioOverride = null, options = {}) {
667
- const io = ioOverride ? { ...defaultIo(), ...ioOverride } : defaultIo();
668
- if (!io.interactive) return { skipped: true };
669
-
670
- const secretsCapable = options.secretsCapable ?? probeLinuxSecretsCapable();
671
- if (!secretsCapable) io.say(linuxKeychainUnavailableMessage());
672
-
673
- io.say('\nMixdog setup wizard — configure before opening Claude Code.');
674
- io.say('Press Enter on any step to skip it.\n');
675
-
676
- const ctx = await loadConfigModules();
677
- ctx.secretsCapable = secretsCapable;
678
- try {
679
- await stepAddressForm(io, ctx);
680
- const discordSaved = await stepDiscordToken(io, ctx);
681
- await stepVoiceTranscription(io, ctx, discordSaved);
682
- await stepWebhookReceiver(io, ctx);
683
- await stepProviderKeys(io, ctx);
684
- await stepPresets(io, ctx);
685
- await stepRolePresetMapping(io, ctx);
686
- await stepSearchBackend(io, ctx);
687
- await stepExplorerPreset(io, ctx);
688
- io.say('\n✓ Wizard complete. Restart Claude Code (or /reload-plugins) to load mixdog.');
689
- } catch (err) {
690
- io.say(`\n✗ Wizard error: ${err?.message || err}`);
691
- throw err;
692
- } finally {
693
- if (typeof io.close === 'function') io.close();
694
- }
695
- return { skipped: false };
696
- }