mixdog 0.7.8 → 0.7.12

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/.claude-plugin/marketplace.json +5 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +40 -0
  4. package/README.md +198 -251
  5. package/bin/statusline-launcher.mjs +5 -1
  6. package/bin/statusline-lib.mjs +14 -6
  7. package/bin/statusline.mjs +14 -6
  8. package/hooks/lib/settings-loader.cjs +4 -3
  9. package/hooks/pre-tool-subagent.cjs +7 -2
  10. package/hooks/session-start.cjs +52 -24
  11. package/lib/mixdog-debug.cjs +163 -0
  12. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  13. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  14. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  15. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  16. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  17. package/package.json +1 -1
  18. package/scripts/builtin-utils-smoke.mjs +14 -8
  19. package/scripts/bump.mjs +80 -0
  20. package/scripts/doctor.mjs +8 -3
  21. package/scripts/mutation-io-smoke.mjs +17 -1
  22. package/scripts/openai-oauth-catalog-smoke.mjs +53 -0
  23. package/scripts/permission-eval-smoke.mjs +18 -1
  24. package/scripts/statusline-launcher-smoke.mjs +2 -2
  25. package/scripts/webhook-selfheal-smoke.mjs +1 -3
  26. package/server-main.mjs +57 -3
  27. package/setup/config-merge.mjs +0 -1
  28. package/setup/install.mjs +241 -51
  29. package/setup/mixdog-cli.mjs +30 -3
  30. package/setup/setup-server.mjs +21 -33
  31. package/setup/setup.html +46 -11
  32. package/setup/tui.mjs +35 -316
  33. package/src/agent/orchestrator/config.mjs +0 -1
  34. package/src/agent/orchestrator/providers/anthropic-oauth.mjs +2 -5
  35. package/src/agent/orchestrator/providers/anthropic.mjs +243 -86
  36. package/src/agent/orchestrator/providers/gemini.mjs +386 -31
  37. package/src/agent/orchestrator/providers/grok-oauth.mjs +2 -5
  38. package/src/agent/orchestrator/providers/model-catalog.mjs +146 -13
  39. package/src/agent/orchestrator/providers/openai-compat-stream.mjs +366 -0
  40. package/src/agent/orchestrator/providers/openai-compat.mjs +74 -30
  41. package/src/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -1
  42. package/src/agent/orchestrator/providers/openai-oauth.mjs +66 -13
  43. package/src/agent/orchestrator/providers/openai-ws.mjs +23 -0
  44. package/src/agent/orchestrator/session/manager.mjs +18 -4
  45. package/src/agent/orchestrator/stall-policy.mjs +6 -0
  46. package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +29 -8
  47. package/src/agent/orchestrator/tools/graph-manifest.json +11 -11
  48. package/src/agent/orchestrator/tools/patch-manifest.json +11 -11
  49. package/src/channels/index.mjs +27 -8
  50. package/src/channels/lib/event-queue.mjs +24 -1
  51. package/src/channels/lib/hook-pipe-server.mjs +21 -8
  52. package/src/channels/lib/webhook.mjs +142 -20
  53. package/src/memory/lib/memory-cycle1.mjs +7 -3
  54. package/src/memory/lib/memory-recall-store.mjs +27 -10
  55. package/src/search/lib/backends/openai-oauth.mjs +6 -2
  56. package/src/search/lib/cache.mjs +55 -7
  57. package/src/shared/config.mjs +1 -1
  58. package/src/shared/llm/cost.mjs +2 -2
  59. package/src/shared/open-url.mjs +37 -0
  60. package/src/shared/seed.mjs +20 -3
  61. package/src/shared/user-data-guard.mjs +3 -1
  62. package/scripts/test-config-rmw-restore.mjs +0 -122
  63. package/setup/wizard.mjs +0 -696
@@ -70,7 +70,23 @@ async function warmRead(path, scope, offset = 0, limit = 20) {
70
70
  }
71
71
 
72
72
  async function rmTree(path) {
73
- await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
73
+ // Windows CI occasionally keeps a just-used temp file locked briefly after the
74
+ // owning JS/native subprocess exits (EACCES/EBUSY/EPERM). Cleanup is not part
75
+ // of the smoke invariant, so retry with backoff and make final teardown
76
+ // best-effort instead of turning a passed mutation suite red.
77
+ const delays = [50, 100, 200, 400, 800];
78
+ let lastErr = null;
79
+ for (let i = 0; i <= delays.length; i++) {
80
+ try {
81
+ await rm(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
82
+ return;
83
+ } catch (err) {
84
+ lastErr = err;
85
+ if (!['EACCES', 'EBUSY', 'EPERM', 'ENOTEMPTY'].includes(err?.code)) break;
86
+ if (i < delays.length) await new Promise((resolve) => setTimeout(resolve, delays[i]));
87
+ }
88
+ }
89
+ console.warn(`mutation-io smoke cleanup warning: failed to remove ${path}: ${lastErr?.code || ''} ${lastErr?.message || lastErr}`);
74
90
  }
75
91
 
76
92
  try {
@@ -0,0 +1,53 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ const tmpData = mkdtempSync(join(tmpdir(), 'mixdog-openai-oauth-catalog-'));
7
+ process.env.CLAUDE_PLUGIN_DATA = tmpData;
8
+
9
+ globalThis.fetch = async (url) => {
10
+ const text = String(url);
11
+ if (text.includes('model_prices_and_context_window')) {
12
+ return {
13
+ ok: true,
14
+ json: async () => ({
15
+ 'openai/gpt-5.5': {
16
+ litellm_provider: 'openai',
17
+ max_input_tokens: 1050000,
18
+ max_output_tokens: 128000,
19
+ input_cost_per_token: 1e-6,
20
+ output_cost_per_token: 2e-6,
21
+ supports_prompt_caching: true,
22
+ },
23
+ }),
24
+ };
25
+ }
26
+ if (text.includes('models.dev')) {
27
+ return { ok: true, json: async () => ({}) };
28
+ }
29
+ throw new Error(`unexpected fetch: ${text}`);
30
+ };
31
+
32
+ try {
33
+ const { enrichModels } = await import('../src/agent/orchestrator/providers/model-catalog.mjs');
34
+ const [model] = await enrichModels([{
35
+ id: 'gpt-5.5',
36
+ provider: 'openai-oauth',
37
+ contextWindow: 272000,
38
+ outputTokens: 32768,
39
+ serviceTiers: [{ id: 'priority', name: 'Fast', description: '1.5x speed' }],
40
+ additionalSpeedTiers: ['fast'],
41
+ }]);
42
+
43
+ assert.equal(model.contextWindow, 272000);
44
+ assert.equal(model.outputTokens, 32768);
45
+ assert.deepEqual(model.serviceTiers, [{ id: 'priority', name: 'Fast', description: '1.5x speed' }]);
46
+ assert.deepEqual(model.additionalSpeedTiers, ['fast']);
47
+ assert.equal(model.inputCostPerM, 1);
48
+ assert.equal(model.outputCostPerM, 2);
49
+
50
+ console.log('openai-oauth catalog smoke: ok');
51
+ } finally {
52
+ rmSync(tmpData, { recursive: true, force: true });
53
+ }
@@ -10,6 +10,23 @@ import path from 'path';
10
10
  import { createRequire } from 'module';
11
11
  import { fileURLToPath } from 'url';
12
12
 
13
+ // The evaluator intentionally merges user-global Claude settings. Local dev
14
+ // machines commonly have defaultMode=bypassPermissions, which made this smoke
15
+ // pass locally while CI's empty user config exercised stricter defaults. Pin
16
+ // the user tier to a throwaway Claude config dir so the smoke is hermetic and
17
+ // still verifies the intended invariant: hard-deny and explicit list rules win
18
+ // before bypass mode.
19
+ const smokeHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'perm-smoke-home-')));
20
+ const smokeConfigDir = path.join(smokeHome, '.claude');
21
+ fs.mkdirSync(smokeConfigDir, { recursive: true });
22
+ fs.writeFileSync(
23
+ path.join(smokeConfigDir, 'settings.json'),
24
+ JSON.stringify({ permissions: { defaultMode: 'bypassPermissions' } }),
25
+ );
26
+ process.env.HOME = smokeHome;
27
+ process.env.USERPROFILE = smokeHome;
28
+ process.env.CLAUDE_CONFIG_DIR = smokeConfigDir;
29
+
13
30
  const _require = createRequire(import.meta.url);
14
31
  const evalPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../hooks/lib/permission-evaluator.cjs');
15
32
  const { evaluatePermission } = _require(evalPath);
@@ -21,7 +38,7 @@ const { loadPermissions, clearSettingsCache } = _require(loaderPath);
21
38
  let pass = 0, fail = 0;
22
39
 
23
40
  function makeProject(settings = {}) {
24
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'perm-smoke-'));
41
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'perm-smoke-')));
25
42
  fs.mkdirSync(path.join(dir, '.claude'), { recursive: true });
26
43
  if (Object.keys(settings).length) {
27
44
  fs.writeFileSync(
@@ -27,7 +27,7 @@ const tmp = mkdtempSync(join(tmpdir(), 'mixdog-sl-launcher-'));
27
27
  function run(homeDir) {
28
28
  return spawnSync(process.execPath, [LAUNCHER], {
29
29
  input: SAMPLE,
30
- env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir },
30
+ env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir, CLAUDE_CONFIG_DIR: join(homeDir, '.claude') },
31
31
  encoding: 'utf8',
32
32
  });
33
33
  }
@@ -60,7 +60,7 @@ try {
60
60
  + "process.stdout.write(await renderStatusLine(i));\n"
61
61
  );
62
62
  const directRun = spawnSync(process.execPath, [driver],
63
- { input: SAMPLE, env: { ...process.env, HOME: tmp, USERPROFILE: tmp }, encoding: 'utf8' });
63
+ { input: SAMPLE, env: { ...process.env, HOME: tmp, USERPROFILE: tmp, CLAUDE_CONFIG_DIR: join(tmp, '.claude') }, encoding: 'utf8' });
64
64
  const direct = directRun.stdout || '';
65
65
 
66
66
  const ok = run(tmp);
@@ -5,9 +5,7 @@
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
 
8
- if (!process.env.CLAUDE_PLUGIN_DATA) {
9
- process.env.CLAUDE_PLUGIN_DATA = join(tmpdir(), `mixdog-webhook-selfheal-smoke-${process.pid}`);
10
- }
8
+ process.env.CLAUDE_PLUGIN_DATA = join(tmpdir(), `mixdog-webhook-selfheal-smoke-${process.pid}`);
11
9
 
12
10
  const { decidePortReclaimAction } = await import('../src/channels/lib/webhook.mjs');
13
11
 
package/server-main.mjs CHANGED
@@ -1040,10 +1040,62 @@ async function _getBridgeLlmFactory() {
1040
1040
  // completion after the worker stopped waiting for the result.
1041
1041
  const AGENT_IPC_MAX_CONCURRENT = 2
1042
1042
  const _agentIpcInflight = new Map()
1043
- /** @type {Array<{ msg: object, worker: string, proc: import('child_process').ChildProcess }>} */
1043
+ /** @type {Array<{ msg: object, worker: string, proc: import('child_process').ChildProcess, lane?: string }>} */
1044
1044
  const _agentIpcQueue = []
1045
1045
  let _agentIpcRunning = 0
1046
1046
 
1047
+ const AGENT_IPC_LANE_USER = 'user'
1048
+ const AGENT_IPC_LANE_MAINTENANCE = 'maintenance'
1049
+ /** @type {Set<string>} */
1050
+ const AGENT_IPC_MAINTENANCE_WORKERS = new Set(['memory'])
1051
+
1052
+ function _normalizeAgentIpcLaneToken(v) {
1053
+ return typeof v === 'string' ? v.trim().toLowerCase() : ''
1054
+ }
1055
+
1056
+ function _isMaintenanceBridgeRole(role) {
1057
+ const r = _normalizeAgentIpcLaneToken(role)
1058
+ if (!r) return false
1059
+ if (r === 'maintenance' || r === 'scheduler-task' || r === 'webhook-handler' || r === 'memory-classification') {
1060
+ return true
1061
+ }
1062
+ if (r.startsWith('cycle')) return true
1063
+ return false
1064
+ }
1065
+
1066
+ /** @returns {'user'|'maintenance'} */
1067
+ function _classifyAgentIpcJobLane(msg, worker) {
1068
+ const lane = _normalizeAgentIpcLaneToken(msg?.lane)
1069
+ if (lane === AGENT_IPC_LANE_MAINTENANCE || lane === 'maint') return AGENT_IPC_LANE_MAINTENANCE
1070
+ if (lane === AGENT_IPC_LANE_USER) return AGENT_IPC_LANE_USER
1071
+
1072
+ const priority = _normalizeAgentIpcLaneToken(msg?.priority)
1073
+ if (priority === AGENT_IPC_LANE_MAINTENANCE || priority === 'maint') return AGENT_IPC_LANE_MAINTENANCE
1074
+ if (priority === AGENT_IPC_LANE_USER) return AGENT_IPC_LANE_USER
1075
+
1076
+ if (msg?.maintenance === true || msg?.isMaintenance === true) return AGENT_IPC_LANE_MAINTENANCE
1077
+
1078
+ const params = msg?.params && typeof msg.params === 'object' ? msg.params : {}
1079
+ if (_isMaintenanceBridgeRole(params.role)) return AGENT_IPC_LANE_MAINTENANCE
1080
+
1081
+ const w = _normalizeAgentIpcLaneToken(worker)
1082
+ if (AGENT_IPC_MAINTENANCE_WORKERS.has(w)) return AGENT_IPC_LANE_MAINTENANCE
1083
+
1084
+ return AGENT_IPC_LANE_USER
1085
+ }
1086
+
1087
+ function _dequeueHighestPriorityAgentIpcJob() {
1088
+ if (_agentIpcQueue.length === 0) return null
1089
+ for (let i = 0; i < _agentIpcQueue.length; i++) {
1090
+ const job = _agentIpcQueue[i]
1091
+ const lane = job.lane || _classifyAgentIpcJobLane(job.msg, job.worker)
1092
+ if (lane !== AGENT_IPC_LANE_MAINTENANCE) {
1093
+ return _agentIpcQueue.splice(i, 1)[0]
1094
+ }
1095
+ }
1096
+ return _agentIpcQueue.shift()
1097
+ }
1098
+
1047
1099
  function _sendAgentIpcResponse(proc, callId, body) {
1048
1100
  try { proc.send({ type: 'agent_ipc_response', callId, ...body }) } catch {}
1049
1101
  }
@@ -1066,14 +1118,16 @@ function _startAgentIpcJob(job) {
1066
1118
 
1067
1119
  function _drainAgentIpcQueue() {
1068
1120
  while (_agentIpcRunning < AGENT_IPC_MAX_CONCURRENT && _agentIpcQueue.length > 0) {
1069
- const job = _agentIpcQueue.shift()
1121
+ const job = _dequeueHighestPriorityAgentIpcJob()
1122
+ if (!job) break
1070
1123
  _agentIpcRunning++
1071
1124
  _startAgentIpcJob(job)
1072
1125
  }
1073
1126
  }
1074
1127
 
1075
1128
  function _enqueueAgentIpcRequest(msg, worker, proc) {
1076
- _agentIpcQueue.push({ msg, worker, proc })
1129
+ const lane = _classifyAgentIpcJobLane(msg, worker)
1130
+ _agentIpcQueue.push({ msg, worker, proc, lane })
1077
1131
  _drainAgentIpcQueue()
1078
1132
  }
1079
1133
 
@@ -25,7 +25,6 @@ const AGENT_PROVIDER_ENV = Object.freeze({
25
25
  gemini: 'GEMINI_API_KEY',
26
26
  deepseek: 'DEEPSEEK_API_KEY',
27
27
  xai: 'XAI_API_KEY',
28
- nvidia: 'NVIDIA_API_KEY',
29
28
  });
30
29
 
31
30
  function envSecretPresent(account) {
package/setup/install.mjs CHANGED
@@ -30,7 +30,8 @@ import { createInterface } from 'node:readline';
30
30
  import { spawn, spawnSync } from 'node:child_process';
31
31
  import { DEFAULT_MARKETPLACE, DEFAULT_PLUGIN } from '../src/shared/plugin-paths.mjs';
32
32
  import { resolveClaudeExecutable } from './locate-claude.mjs';
33
- import { createSpinner } from './tui.mjs';
33
+ import { confirm, createSpinner, outro } from './tui.mjs';
34
+ import { openInBrowser } from '../src/shared/open-url.mjs';
34
35
 
35
36
  const MARKETPLACE = DEFAULT_MARKETPLACE;
36
37
  const PLUGIN_REF = `${DEFAULT_PLUGIN}@${DEFAULT_MARKETPLACE}`;
@@ -64,6 +65,21 @@ function loadSettings(file) {
64
65
  return JSON.parse(raw);
65
66
  }
66
67
 
68
+ /** True when user settings already enable the published plugin ref (deterministic read). */
69
+ export function isPluginRegistered() {
70
+ const file = join(claudeConfigBaseDir(), 'settings.json');
71
+ if (!existsSync(file)) return false;
72
+ try {
73
+ const raw = readFileSync(file, 'utf8');
74
+ if (raw.trim() === '') return false;
75
+ const settings = JSON.parse(raw);
76
+ if (!isPlainObject(settings)) return false;
77
+ return settings?.enabledPlugins?.[PLUGIN_REF] === true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
67
83
  function isPlainObject(value) {
68
84
  return value !== null && typeof value === 'object' && !Array.isArray(value);
69
85
  }
@@ -196,26 +212,91 @@ async function ensureClaudeInstalled(dryRun = false) {
196
212
  console.log(CLAUDE_SETUP_GUIDANCE);
197
213
  }
198
214
 
199
- const WIZARD_STEP_LABELS = [
200
- 'Address form',
201
- 'Discord bot token',
202
- 'Voice transcription',
203
- 'Webhook receiver',
204
- 'Provider API keys',
205
- 'Model presets',
206
- 'Role preset mapping',
207
- 'Search backend',
208
- 'Explorer maintenance preset',
215
+ // OAuth logins for the agent runtime. Claude (anthropic-oauth) powers every
216
+ // default preset so it is recommended; Codex (openai-oauth) is an optional
217
+ // extra. Both complete a browser-PKCE login via a localhost callback, so an
218
+ // inline login works (`offerLogin: true`).
219
+ //
220
+ // Grok Build (grok-oauth) is DETECT-ONLY (`offerLogin: false`): xAI's consent
221
+ // page shows a code to copy instead of redirecting to our localhost callback,
222
+ // so an inline login here would dead-end (the user gets a key with nowhere to
223
+ // put it). Its real login is the official `grok` CLI, which handles that paste
224
+ // and writes ~/.grok/auth.json — mixdog reads that file (and its own store),
225
+ // so we only DETECT Grok and point at the CLI when it is missing.
226
+ //
227
+ // Each provider module exposes the same shape — a `has…Credentials` detector
228
+ // and (for offerLogin providers) a browser-PKCE `loginOAuth` resolving a truthy
229
+ // token object on success or null on failure. Already authenticated →
230
+ // auto-skip. All branches are invariant-gated (creds present? · offerLogin? ·
231
+ // TTY? · login result?) — no heuristic fallback.
232
+ const OAUTH_PROVIDERS = [
233
+ {
234
+ label: 'Claude',
235
+ module: '../src/agent/orchestrator/providers/anthropic-oauth.mjs',
236
+ has: 'hasAnthropicOAuthCredentials',
237
+ offerLogin: true,
238
+ recommended: true,
239
+ },
240
+ {
241
+ label: 'Codex (ChatGPT)',
242
+ module: '../src/agent/orchestrator/providers/openai-oauth.mjs',
243
+ has: 'hasOpenAIOAuthCredentials',
244
+ offerLogin: true,
245
+ recommended: false,
246
+ },
247
+ {
248
+ label: 'Grok Build',
249
+ module: '../src/agent/orchestrator/providers/grok-oauth.mjs',
250
+ has: 'hasGrokOAuthCredentials',
251
+ offerLogin: false,
252
+ hint: 'log in with the `grok` CLI — mixdog reads ~/.grok/auth.json automatically',
253
+ },
209
254
  ];
210
255
 
211
- function logDryRunWizard() {
212
- console.log('[dry-run] would run the setup wizard (9 steps):');
213
- for (let i = 0; i < WIZARD_STEP_LABELS.length; i += 1) {
214
- console.log(`[dry-run] ${i + 1}. ${WIZARD_STEP_LABELS[i]}`);
256
+ async function ensureOAuthLogins(dryRun = false) {
257
+ const interactive = process.stdin.isTTY && !process.env.CI;
258
+ for (const p of OAUTH_PROVIDERS) {
259
+ const mod = await import(p.module);
260
+ if (mod[p.has]()) {
261
+ console.log(dryRun ? `[dry-run] ${p.label} login detected` : `✓ ${p.label} login detected.`);
262
+ continue;
263
+ }
264
+ // Detect-only providers (Grok): never open an inline login — point at the
265
+ // provider's own CLI, which mixdog auto-detects via its credential file.
266
+ if (!p.offerLogin) {
267
+ console.log(
268
+ dryRun
269
+ ? `[dry-run] no ${p.label} login — would point to: ${p.hint}`
270
+ : `${p.label}: not logged in — ${p.hint}.`,
271
+ );
272
+ continue;
273
+ }
274
+ if (dryRun) {
275
+ console.log(`[dry-run] no ${p.label} login — would offer to log in via the browser`);
276
+ continue;
277
+ }
278
+ if (!interactive) {
279
+ console.log(`${p.label}: not logged in — log in anytime via /setup.`);
280
+ continue;
281
+ }
282
+ const yes = await confirm(`Log in to ${p.label} now? (opens your browser)`, {
283
+ initial: p.recommended,
284
+ });
285
+ if (!yes) {
286
+ console.log(`Skipped ${p.label} — log in anytime via /setup.`);
287
+ continue;
288
+ }
289
+ const result = await mod.loginOAuth();
290
+ console.log(
291
+ result
292
+ ? `✓ ${p.label} login complete.`
293
+ : `${p.label} login did not complete — retry anytime via /setup.`,
294
+ );
215
295
  }
216
296
  }
217
297
 
218
298
  async function runInstallDemo() {
299
+ process.env.MIXDOG_SETUP_QUIET = '1';
219
300
  const tmpRoot = mkdtempSync(join(tmpdir(), 'mixdog-demo-'));
220
301
  const configDir = join(tmpRoot, 'config');
221
302
  const dataDir = join(tmpRoot, 'data');
@@ -230,7 +311,7 @@ async function runInstallDemo() {
230
311
  } catch {}
231
312
  });
232
313
 
233
- console.log('\n🎬 mixdog demo — real wizard UI, nothing saved (isolated temp, auto-cleaned).\n');
314
+ console.log('\n🎬 mixdog demo — install preview, nothing saved (isolated temp, auto-cleaned).\n');
234
315
 
235
316
  const claudePath = resolveClaudeExecutable();
236
317
  if (claudePath) {
@@ -241,18 +322,27 @@ async function runInstallDemo() {
241
322
 
242
323
  console.log('[demo] skipping plugin registration');
243
324
 
244
- const { runSetupWizard } = await import('./wizard.mjs');
245
- await runSetupWizard();
325
+ for (const p of OAUTH_PROVIDERS) {
326
+ const mod = await import(p.module);
327
+ console.log(
328
+ mod[p.has]()
329
+ ? `[demo] ${p.label} login detected`
330
+ : `[demo] no ${p.label} login — ${p.offerLogin ? 'would offer a browser login' : `would point to: ${p.hint}`}`,
331
+ );
332
+ }
246
333
 
247
334
  console.log('[demo] skipping runtime dependency install');
248
335
 
336
+ await runFinale({ demo: true });
337
+
249
338
  console.log('\n✓ Demo complete — nothing was saved to your real config.');
339
+ console.log('Run `mixdog install` to set up for real.');
250
340
  try {
251
341
  rmSync(tmpRoot, { recursive: true, force: true });
252
342
  } catch {}
253
343
  }
254
344
 
255
- export async function runInstall() {
345
+ export async function runInstall({ launchAfter = false } = {}) {
256
346
  const demo =
257
347
  process.argv.includes('--demo') || process.env.MIXDOG_SETUP_DEMO === '1';
258
348
  if (demo) {
@@ -260,6 +350,8 @@ export async function runInstall() {
260
350
  return;
261
351
  }
262
352
 
353
+ process.env.MIXDOG_SETUP_QUIET = '1';
354
+
263
355
  const dryRun =
264
356
  process.argv.includes('--dry-run') || process.env.MIXDOG_SETUP_DRY_RUN === '1';
265
357
 
@@ -270,24 +362,29 @@ export async function runInstall() {
270
362
  await ensureClaudeInstalled(dryRun);
271
363
  registerPluginInSettings(dryRun);
272
364
 
273
- // npx / node setup/install.mjs runs outside Claude Code — config.mjs needs a data dir.
365
+ // npx / node setup/install.mjs runs outside Claude Code. The provider modules
366
+ // imported by ensureOAuthLogins (below) pull in config.mjs, which resolves
367
+ // plugin-data AT IMPORT TIME and throws if neither CLAUDE_PLUGIN_DATA nor
368
+ // CLAUDE_PLUGIN_ROOT is set. Export it before any provider import — in
369
+ // --dry-run too: this only sets a process env var, no files are written.
274
370
  const pluginData = process.env.CLAUDE_PLUGIN_DATA;
275
371
  const dataDir =
276
372
  pluginData && String(pluginData).trim() ? String(pluginData).trim() : defaultPluginDataDir();
277
- if (!dryRun && (!pluginData || !String(pluginData).trim())) {
278
- process.env.CLAUDE_PLUGIN_DATA = defaultPluginDataDir();
373
+ if (!pluginData || !String(pluginData).trim()) {
374
+ process.env.CLAUDE_PLUGIN_DATA = dataDir;
279
375
  }
280
376
 
281
- if (dryRun) {
282
- logDryRunWizard();
283
- } else {
284
- const { runSetupWizard } = await import('./wizard.mjs');
285
- await runSetupWizard();
286
- }
377
+ // No interactive wizard. The full default config (presets, role→preset
378
+ // mapping, search backend, maintenance) is seeded on first launch by the
379
+ // agent boot + setup server; per-feature config (Discord, voice, webhooks,
380
+ // address, provider keys) lives in the /setup UI. Install only secures the
381
+ // OAuth logins (Claude / Codex / Grok Build) so agents work the moment
382
+ // mixdog launches.
383
+ await ensureOAuthLogins(dryRun);
287
384
 
288
385
  await prewarmRuntimeDepsBestEffort(dryRun, dataDir);
289
386
 
290
- if (!dryRun) maybeStarNudge();
387
+ if (!dryRun) await runFinale({ launchAfter });
291
388
  }
292
389
 
293
390
  async function prewarmRuntimeDepsBestEffort(dryRun = false, dataDirOverride = null) {
@@ -339,31 +436,120 @@ async function prewarmRuntimeDepsBestEffort(dryRun = false, dataDirOverride = nu
339
436
  }
340
437
  }
341
438
 
342
- // Non-blocking, opt-in star nudge. Only on a real interactive terminal never
343
- // in CI or piped/non-TTY installs, so it can never break an automated setup.
344
- function maybeStarNudge() {
345
- if (!process.stdin.isTTY || process.env.CI) return;
346
- const rl = createInterface({ input: process.stdin, output: process.stdout });
347
- rl.question('\n⭐ Found this useful? Star the repo on GitHub? (y/N) ', (answer) => {
348
- const a = answer.trim().toLowerCase();
349
- if (a === 'y' || a === 'yes') openRepo();
350
- else console.log(`No problem — you can star it anytime at ${REPO_URL}`);
351
- rl.close();
352
- });
439
+ async function runFinale({ demo = false, launchAfter = false } = {}) {
440
+ console.log('\n── All set ──');
441
+ console.log('🎉 mixdog is configured.');
442
+
443
+ const interactive = process.stdin.isTTY && !process.env.CI;
444
+ if (interactive) {
445
+ const star = await confirm('Star mixdog on GitHub?', { initial: true });
446
+ if (star) {
447
+ if (demo) {
448
+ console.log(`[demo] would star ${REPO} via GH_TOKEN/gh (or open ${REPO_URL} as a fallback)`);
449
+ } else if (await starRepo()) {
450
+ console.log(`⭐ Starred ${REPO} — thank you! 🙏`);
451
+ } else {
452
+ // No usable local GitHub credential (env token / gh) — hand off to the
453
+ // browser, where the user is already signed in to github.com.
454
+ openRepo();
455
+ }
456
+ } else {
457
+ console.log(`No problem — you can star it anytime at ${REPO_URL}`);
458
+ }
459
+ }
460
+
461
+ if (demo) {
462
+ outro('Demo finished — no changes were saved.');
463
+ releaseInstallerStdin();
464
+ return;
465
+ }
466
+ outro(
467
+ launchAfter
468
+ ? 'Setup complete — launching Claude Code with mixdog…'
469
+ : 'Setup complete — run `mixdog` to launch Claude Code with mixdog (or restart it if already open).',
470
+ );
471
+ releaseInstallerStdin();
353
472
  }
354
473
 
355
- function openRepo() {
356
- const platform = process.platform;
357
- const cmd = platform === 'win32' ? 'cmd' : platform === 'darwin' ? 'open' : 'xdg-open';
358
- const args = platform === 'win32' ? ['/c', 'start', '', REPO_URL] : [REPO_URL];
474
+ /** Belt-and-suspenders after TUI prompts so direct `node setup/install.mjs` can exit. */
475
+ function releaseInstallerStdin() {
476
+ const stdin = process.stdin;
477
+ try {
478
+ if (stdin.isTTY) stdin.setRawMode(false);
479
+ } catch {
480
+ /* ignore */
481
+ }
482
+ stdin.removeAllListeners('keypress');
483
+ stdin.removeAllListeners('data');
484
+ stdin.pause();
485
+ }
486
+
487
+ /**
488
+ * Star the repo without a browser when a local GitHub credential is available,
489
+ * following the standard CLI pattern (gh primary, GITHUB_TOKEN secondary —
490
+ * confirmed against GitHub docs / gh manual). Order: an explicitly-set env
491
+ * token first (deliberate, e.g. CI), then the authenticated `gh` CLI. Returns
492
+ * true once one succeeds; false when none is usable, so the caller opens the
493
+ * browser. Starring needs the user's GitHub identity — there is no
494
+ * unauthenticated path.
495
+ */
496
+ async function starRepo() {
497
+ const envToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
498
+ if (envToken && (await starViaToken(envToken))) return true;
499
+ return starRepoViaGh();
500
+ }
501
+
502
+ /**
503
+ * Star via the REST API with a bearer token (PUT /user/starred/:owner/:repo →
504
+ * HTTP 204). `public_repo` scope suffices for a public repo, `repo` for a
505
+ * private one. Returns true on 204, false on any non-204 / network error.
506
+ */
507
+ async function starViaToken(token) {
508
+ try {
509
+ const res = await fetch(`https://api.github.com/user/starred/${REPO}`, {
510
+ method: 'PUT',
511
+ headers: {
512
+ Authorization: `Bearer ${token}`,
513
+ Accept: 'application/vnd.github+json',
514
+ 'X-GitHub-Api-Version': '2022-11-28',
515
+ 'Content-Length': '0',
516
+ 'User-Agent': 'mixdog-installer',
517
+ },
518
+ });
519
+ return res.status === 204;
520
+ } catch {
521
+ return false;
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Star the repo directly via the authenticated `gh` CLI
527
+ * (PUT /user/starred/:owner/:repo → HTTP 204). Returns true on success.
528
+ * Returns false when gh is absent / not logged in / the call fails, so the
529
+ * caller falls back to opening the browser — starring requires the user's
530
+ * GitHub identity, which only gh provides here. Path has no leading slash so
531
+ * a shell never rewrites it; shell:true lets Windows resolve gh.exe on PATH.
532
+ */
533
+ function starRepoViaGh() {
359
534
  try {
360
- spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
361
- console.log('Opening the repo in your browser — thank you! 🙏');
535
+ const r = spawnSync(
536
+ 'gh',
537
+ ['api', '--method', 'PUT', `user/starred/${REPO}`, '--silent'],
538
+ { stdio: 'ignore', timeout: 15000, shell: true },
539
+ );
540
+ return r.status === 0;
362
541
  } catch {
363
- console.log(`Please visit ${REPO_URL} to star. Thank you! 🙏`);
542
+ return false;
364
543
  }
365
544
  }
366
545
 
546
+ function openRepo() {
547
+ // Shared opener: rundll32 on Windows (the old `cmd start "" url` worked only
548
+ // because REPO_URL has no query string; the helper is correct regardless).
549
+ openInBrowser(REPO_URL);
550
+ console.log(`Opening the repo in your browser — thank you! 🙏 (if it didn't open: ${REPO_URL})`);
551
+ }
552
+
367
553
  function isInstallerEntry() {
368
554
  const entry = process.argv[1];
369
555
  if (!entry) return false;
@@ -377,8 +563,12 @@ function isInstallerEntry() {
377
563
  }
378
564
 
379
565
  if (isInstallerEntry()) {
380
- runInstall().catch((err) => {
381
- console.error(err?.stack || err?.message || String(err));
382
- process.exit(1);
383
- });
566
+ runInstall()
567
+ .then(() => {
568
+ process.stdout.write('', () => process.exit(0));
569
+ })
570
+ .catch((err) => {
571
+ console.error(err?.stack || err?.message || String(err));
572
+ process.exit(1);
573
+ });
384
574
  }