mixdog 0.7.6 → 0.7.8

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.
@@ -19,10 +19,14 @@ import { fileURLToPath } from 'url';
19
19
  import { createRequire } from 'module';
20
20
  import { dirname, join } from 'path';
21
21
  import * as fs from 'fs';
22
- import { createHash, randomUUID } from 'crypto';
23
- import { execSync, spawn, spawnSync } from 'child_process';
22
+ import { randomUUID } from 'crypto';
23
+ import { execSync, spawn } from 'child_process';
24
24
  import * as os from 'os';
25
- import { assertSafeOwnedDir } from '../src/shared/user-data-guard.mjs';
25
+ import {
26
+ ensureRuntimeDeps,
27
+ hasRequiredDeps,
28
+ renameWithRetrySync,
29
+ } from './ensure-deps.mjs';
26
30
 
27
31
  // Stable per-terminal session id for this proxy supervisor's lifetime. The
28
32
  // child server.mjs is respawned on crash / dev-sync restart, but THIS
@@ -34,29 +38,6 @@ import { assertSafeOwnedDir } from '../src/shared/user-data-guard.mjs';
34
38
  // upstream-provided id if one already exists.
35
39
  const STABLE_TERMINAL_SESSION_ID = process.env.MIXDOG_SESSION_ID || randomUUID();
36
40
 
37
- const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EEXIST']);
38
- const RENAME_BACKOFFS_MS = Object.freeze([25, 50, 100, 200, 400, 800, 1200, 1600]);
39
- function sleepSync(ms) {
40
- try {
41
- const buf = new SharedArrayBuffer(4);
42
- Atomics.wait(new Int32Array(buf), 0, 0, Math.max(1, Number(ms) || 1));
43
- } catch {}
44
- }
45
- function renameWithRetrySync(src, dst) {
46
- let lastErr = null;
47
- for (let attempt = 0; attempt <= RENAME_BACKOFFS_MS.length; attempt++) {
48
- try {
49
- fs.renameSync(src, dst);
50
- return true;
51
- } catch (err) {
52
- lastErr = err;
53
- if (!RENAME_RETRY_CODES.has(err?.code) || attempt >= RENAME_BACKOFFS_MS.length) break;
54
- sleepSync(RENAME_BACKOFFS_MS[attempt] + Math.floor(Math.random() * 50));
55
- }
56
- }
57
- throw lastErr;
58
- }
59
-
60
41
  const __dirname = dirname(fileURLToPath(import.meta.url));
61
42
  const __localRoot = join(__dirname, '..');
62
43
 
@@ -87,7 +68,6 @@ if (pluginRoot !== __localRoot) {
87
68
  }
88
69
  const serverPath = join(pluginRoot, 'server.mjs');
89
70
  const pluginPkg = join(pluginRoot, 'package.json');
90
- const pluginLock = join(pluginRoot, 'bun.lock');
91
71
  const pluginNm = join(pluginRoot, 'node_modules');
92
72
 
93
73
  process.stderr.write(`[boot-time] tag=run-mcp-entry tMs=${Date.now()}\n`);
@@ -110,17 +90,6 @@ try {
110
90
  // server.mjs reads MIXDOG_SUPERVISOR_PID + MIXDOG_SUPERVISOR_CACHE_DIR
111
91
  // from its env (set in spawnChild below) to identify the supervisor.
112
92
 
113
- const requiredDepNames = [
114
- ['@modelcontextprotocol', 'sdk', 'package.json'],
115
- ['zod', 'package.json'],
116
- ['zod-to-json-schema', 'package.json'],
117
- ['openai', 'package.json'],
118
- ];
119
-
120
- function hasRequiredDeps(nmDir) {
121
- return requiredDepNames.every((parts) => fs.existsSync(join(nmDir, ...parts)));
122
- }
123
-
124
93
  // ── Lightweight JSON-RPC line scanner ────────────────────────────────────────
125
94
  // Extracts `id` and `method` from a JSON-RPC line without a full JSON.parse.
126
95
  // Returns { id, method } (each may be undefined), or null on scan failure.
@@ -256,50 +225,6 @@ function _scanIdMethod(line) {
256
225
  }
257
226
  }
258
227
 
259
- const LOCK_POLL_MS = 250;
260
- const LOCK_MAX_MS = 15 * 60 * 1000;
261
- const LOCK_XHOST_MS = 10 * 60 * 1000;
262
-
263
- function acquireLock(lockFile) {
264
- const start = Date.now();
265
- while (Date.now() - start < LOCK_MAX_MS) {
266
- try {
267
- const body = JSON.stringify({
268
- pid: process.pid,
269
- hostname: os.hostname(),
270
- startedAt: Date.now(),
271
- });
272
- // 'wx' = O_CREAT | O_EXCL — fails atomically if file already exists.
273
- fs.writeFileSync(lockFile, body, { flag: 'wx' });
274
- return;
275
- } catch (e) {
276
- if (e.code !== 'EEXIST') throw e;
277
- try {
278
- const raw = fs.readFileSync(lockFile, 'utf8');
279
- const body = JSON.parse(raw);
280
- const st = fs.statSync(lockFile);
281
- const sameHost = body.hostname === os.hostname();
282
- let dead = false;
283
- if (sameHost) {
284
- try { process.kill(body.pid, 0); }
285
- catch (ke) { if (ke.code === 'ESRCH') dead = true; }
286
- } else {
287
- if (Date.now() - st.mtimeMs > LOCK_XHOST_MS) dead = true;
288
- }
289
- if (dead) fs.unlinkSync(lockFile);
290
- } catch { /* lock may have been released between read and stat — retry */ }
291
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_POLL_MS);
292
- }
293
- }
294
- throw new Error(
295
- `timed out waiting for dependency install lock after ${LOCK_MAX_MS / 60000} minutes`
296
- );
297
- }
298
-
299
- function releaseLock(lockFile) {
300
- try { fs.unlinkSync(lockFile); } catch {}
301
- }
302
-
303
228
  function ensureNmSymlink(linkPath, targetPath) {
304
229
  const linkType = process.platform === 'win32' ? 'junction' : 'dir';
305
230
  // EPERM/EBUSY here is almost always a transient AV / indexer lock on the
@@ -353,32 +278,6 @@ function ensureNmSymlink(linkPath, targetPath) {
353
278
  trySymlink();
354
279
  }
355
280
 
356
- function sha256(buf) {
357
- return createHash('sha256').update(buf).digest('hex');
358
- }
359
-
360
- /**
361
- * SHA-256 hash that changes iff the resolved dep tree changes.
362
- * Primary: bun.lock. Fallback: dep-key objects from package.json (so the very
363
- * first install — before bun.lock exists — still hashes deterministically).
364
- */
365
- function computeDepHash(pkgJsonPath, pkgLockPath) {
366
- if (fs.existsSync(pkgLockPath)) {
367
- return sha256(fs.readFileSync(pkgLockPath));
368
- }
369
- const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
370
- const depKeys = ['dependencies', 'optionalDependencies', 'peerDependencies'];
371
- const depObj = {};
372
- for (const k of depKeys) {
373
- if (pkg[k]) {
374
- depObj[k] = Object.fromEntries(
375
- Object.entries(pkg[k]).sort(([a], [b]) => a.localeCompare(b))
376
- );
377
- }
378
- }
379
- return sha256(Buffer.from(JSON.stringify(depObj)));
380
- }
381
-
382
281
  const require = createRequire(import.meta.url);
383
282
  const { resolvePluginData } = require('../lib/plugin-paths.cjs');
384
283
  const dataDir = resolvePluginData();
@@ -433,67 +332,13 @@ try {
433
332
  // Install runtime deps into a DEDICATED <dataDir>/.deps/ subdir — NEVER the
434
333
  // data root, which holds user data (mixdog-config.json, user-workflow.*,
435
334
  // roles/). Running `bun install` with cwd=dataDir would wipe that state.
436
- const depsDir = join(dataDir, '.deps');
437
- const sharedPkg = join(depsDir, 'package.json');
438
- const sharedLock = join(depsDir, 'bun.lock');
439
- const sharedNm = join(depsDir, 'node_modules');
440
- const stamp = join(depsDir, '.deps-stamp');
441
- const stampTmp = join(depsDir, '.deps-stamp.tmp');
442
- const lockFile = join(depsDir, '.install.lock');
443
-
444
- const currentHash = computeDepHash(pluginPkg, pluginLock);
445
- let storedHash = '';
446
- try { storedHash = fs.readFileSync(stamp, 'utf8').trim(); } catch {}
447
-
448
- const needsInstall = (currentHash !== storedHash) || !hasRequiredDeps(sharedNm);
449
-
450
- if (needsInstall) {
451
- // Hard guard: refuse to install anywhere that would clobber user data.
452
- // assertSafeOwnedDir throws unless depsDir is an owned subdir (.deps).
453
- assertSafeOwnedDir(depsDir, dataDir, 'bun install');
454
- fs.mkdirSync(depsDir, { recursive: true });
455
- acquireLock(lockFile);
456
- try {
457
- fs.copyFileSync(pluginPkg, sharedPkg);
458
- if (fs.existsSync(pluginLock)) fs.copyFileSync(pluginLock, sharedLock);
459
-
460
- const args = fs.existsSync(sharedLock)
461
- ? ['install', '--frozen-lockfile']
462
- : ['install'];
463
- process.stderr.write(`[run-mcp] installing shared deps: bun ${args.join(' ')}\n`);
335
+ ensureRuntimeDeps({
336
+ dataDir,
337
+ pluginRoot,
338
+ bunPath: process.env.BUN_EXEC_PATH,
339
+ });
464
340
 
465
- // First install on a clean machine downloads + extracts all deps, which
466
- // routinely exceeds 30s; too low a ceiling times out into an empty
467
- // node_modules and aborts the very first boot. 3 minutes covers a cold
468
- // network fetch while still bounding a genuinely stuck install.
469
- const INSTALL_TIMEOUT_MS = 180_000;
470
- const result = spawnSync(process.env.BUN_EXEC_PATH || process.execPath, args, {
471
- cwd: depsDir,
472
- stdio: 'inherit',
473
- timeout: INSTALL_TIMEOUT_MS,
474
- windowsHide: true,
475
- });
476
- if (result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGTERM') {
477
- process.stderr.write(
478
- `[run-mcp] WARN: bun install timed out after ${INSTALL_TIMEOUT_MS}ms — ` +
479
- `continuing with existing node_modules (stale lock removed)\n`
480
- );
481
- try { fs.unlinkSync(lockFile); } catch {}
482
- } else if (result.status !== 0) {
483
- const detail = result.status ?? result.signal ?? 'unknown';
484
- process.stderr.write(
485
- `[run-mcp] WARN: bun install exited with status ${detail} — ` +
486
- `continuing with existing node_modules if available\n`
487
- );
488
- } else {
489
- // Atomic stamp write: tmp + rename so a crash cannot leave it half-written.
490
- fs.writeFileSync(stampTmp, currentHash);
491
- renameWithRetrySync(stampTmp, stamp);
492
- }
493
- } finally {
494
- releaseLock(lockFile);
495
- }
496
- }
341
+ const sharedNm = join(dataDir, '.deps', 'node_modules');
497
342
 
498
343
  ensureNmSymlink(pluginNm, sharedNm);
499
344
 
package/setup/install.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // install.mjs — one-shot bootstrapper that registers the mixdog plugin in the
3
3
  // user's Claude Code settings so it auto-loads on the next session start.
4
4
  //
5
- // Run via: npx mixdog setup | mixdog-install (after the package is published)
5
+ // Run via: npx mixdog | mixdog install | mixdog-install (after publish)
6
6
  // or: node setup/install.mjs (from a checkout)
7
7
  //
8
8
  // It merges two keys into the user-scope settings file (preserving everything
@@ -19,14 +19,18 @@ import {
19
19
  existsSync,
20
20
  mkdirSync,
21
21
  copyFileSync,
22
+ mkdtempSync,
23
+ rmSync,
22
24
  } from 'node:fs';
23
- import { join } from 'node:path';
24
- import { homedir } from 'node:os';
25
+ import { join, dirname } from 'node:path';
26
+ import { homedir, tmpdir } from 'node:os';
25
27
  import { realpathSync } from 'node:fs';
26
28
  import { fileURLToPath } from 'node:url';
27
29
  import { createInterface } from 'node:readline';
28
- import { spawn } from 'node:child_process';
30
+ import { spawn, spawnSync } from 'node:child_process';
29
31
  import { DEFAULT_MARKETPLACE, DEFAULT_PLUGIN } from '../src/shared/plugin-paths.mjs';
32
+ import { resolveClaudeExecutable } from './locate-claude.mjs';
33
+ import { createSpinner } from './tui.mjs';
30
34
 
31
35
  const MARKETPLACE = DEFAULT_MARKETPLACE;
32
36
  const PLUGIN_REF = `${DEFAULT_PLUGIN}@${DEFAULT_MARKETPLACE}`;
@@ -64,7 +68,7 @@ function isPlainObject(value) {
64
68
  return value !== null && typeof value === 'object' && !Array.isArray(value);
65
69
  }
66
70
 
67
- function registerPluginInSettings() {
71
+ function registerPluginInSettings(dryRun = false) {
68
72
  const dir = settingsDir();
69
73
  const file = join(dir, 'settings.json');
70
74
 
@@ -90,6 +94,15 @@ function registerPluginInSettings() {
90
94
  const already = settings.enabledPlugins[PLUGIN_REF] === true;
91
95
  settings.enabledPlugins[PLUGIN_REF] = true;
92
96
 
97
+ if (dryRun) {
98
+ console.log(`[dry-run] would register mixdog in ${file}`);
99
+ console.log(`[dry-run] marketplace "${MARKETPLACE}" → github:${REPO}`);
100
+ console.log(
101
+ `[dry-run] enabled plugin "${PLUGIN_REF}"${already ? ' (was already enabled)' : ''}`,
102
+ );
103
+ return;
104
+ }
105
+
93
106
  // Back up an existing file before the first write; create the dir otherwise.
94
107
  if (existsSync(file)) {
95
108
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -107,19 +120,223 @@ function registerPluginInSettings() {
107
120
  console.log(`\nNext: restart Claude Code (or run /reload-plugins). mixdog loads automatically.`);
108
121
  }
109
122
 
123
+ const CLAUDE_SETUP_GUIDANCE =
124
+ 'Install Claude Code first: https://code.claude.com/docs/en/setup';
125
+
126
+ function officialClaudeInstallerCommandDescription() {
127
+ if (process.platform === 'win32') {
128
+ return 'powershell -NoProfile -Command "irm https://claude.ai/install.ps1 | iex"';
129
+ }
130
+ return 'bash -c "curl -fsSL https://claude.ai/install.sh | bash"';
131
+ }
132
+
133
+ function runOfficialClaudeInstaller() {
134
+ if (process.platform === 'win32') {
135
+ spawnSync(
136
+ 'powershell.exe',
137
+ ['-NoProfile', '-Command', 'irm https://claude.ai/install.ps1 | iex'],
138
+ { stdio: 'inherit', windowsHide: true },
139
+ );
140
+ } else {
141
+ spawnSync('bash', ['-c', 'curl -fsSL https://claude.ai/install.sh | bash'], {
142
+ stdio: 'inherit',
143
+ });
144
+ }
145
+ }
146
+
147
+ function askInstallClaude() {
148
+ return new Promise((resolve) => {
149
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
150
+ rl.question(
151
+ 'Claude Code CLI not found. Install it now with the official installer? (y/N) ',
152
+ (answer) => {
153
+ rl.close();
154
+ const a = answer.trim().toLowerCase();
155
+ resolve(a === 'y' || a === 'yes');
156
+ },
157
+ );
158
+ });
159
+ }
160
+
161
+ async function ensureClaudeInstalled(dryRun = false) {
162
+ const claudePath = resolveClaudeExecutable();
163
+ if (claudePath) {
164
+ if (dryRun) console.log(`[dry-run] Claude Code detected at ${claudePath}`);
165
+ return;
166
+ }
167
+
168
+ if (dryRun) {
169
+ const cmd = officialClaudeInstallerCommandDescription();
170
+ console.log(
171
+ `[dry-run] Claude Code NOT found — would prompt to install via the official installer (${cmd})`,
172
+ );
173
+ return;
174
+ }
175
+
176
+ const interactive = process.stdin.isTTY && !process.env.CI;
177
+
178
+ if (!interactive) {
179
+ console.log(CLAUDE_SETUP_GUIDANCE);
180
+ return;
181
+ }
182
+
183
+ const yes = await askInstallClaude();
184
+ if (yes) {
185
+ runOfficialClaudeInstaller();
186
+ if (resolveClaudeExecutable()) {
187
+ console.log('✓ Claude Code detected.');
188
+ return;
189
+ }
190
+ console.log(
191
+ "✓ Claude Code installed — open a NEW terminal so 'claude' is on PATH, then start Claude Code (mixdog is being registered now).",
192
+ );
193
+ return;
194
+ }
195
+
196
+ console.log(CLAUDE_SETUP_GUIDANCE);
197
+ }
198
+
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',
209
+ ];
210
+
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]}`);
215
+ }
216
+ }
217
+
218
+ async function runInstallDemo() {
219
+ const tmpRoot = mkdtempSync(join(tmpdir(), 'mixdog-demo-'));
220
+ const configDir = join(tmpRoot, 'config');
221
+ const dataDir = join(tmpRoot, 'data');
222
+ process.env.CLAUDE_CONFIG_DIR = configDir;
223
+ process.env.CLAUDE_PLUGIN_DATA = dataDir;
224
+ mkdirSync(configDir, { recursive: true });
225
+ mkdirSync(dataDir, { recursive: true });
226
+
227
+ process.on('exit', () => {
228
+ try {
229
+ rmSync(tmpRoot, { recursive: true, force: true });
230
+ } catch {}
231
+ });
232
+
233
+ console.log('\n🎬 mixdog demo — real wizard UI, nothing saved (isolated temp, auto-cleaned).\n');
234
+
235
+ const claudePath = resolveClaudeExecutable();
236
+ if (claudePath) {
237
+ console.log(`[demo] Claude Code detected at ${claudePath}`);
238
+ } else {
239
+ console.log('[demo] Claude Code not detected');
240
+ }
241
+
242
+ console.log('[demo] skipping plugin registration');
243
+
244
+ const { runSetupWizard } = await import('./wizard.mjs');
245
+ await runSetupWizard();
246
+
247
+ console.log('[demo] skipping runtime dependency install');
248
+
249
+ console.log('\n✓ Demo complete — nothing was saved to your real config.');
250
+ try {
251
+ rmSync(tmpRoot, { recursive: true, force: true });
252
+ } catch {}
253
+ }
254
+
110
255
  export async function runInstall() {
111
- registerPluginInSettings();
256
+ const demo =
257
+ process.argv.includes('--demo') || process.env.MIXDOG_SETUP_DEMO === '1';
258
+ if (demo) {
259
+ await runInstallDemo();
260
+ return;
261
+ }
262
+
263
+ const dryRun =
264
+ process.argv.includes('--dry-run') || process.env.MIXDOG_SETUP_DRY_RUN === '1';
265
+
266
+ if (dryRun) {
267
+ console.log('[dry-run] mixdog install preview — no files, prompts, or installs');
268
+ }
269
+
270
+ await ensureClaudeInstalled(dryRun);
271
+ registerPluginInSettings(dryRun);
112
272
 
113
273
  // npx / node setup/install.mjs runs outside Claude Code — config.mjs needs a data dir.
114
274
  const pluginData = process.env.CLAUDE_PLUGIN_DATA;
115
- if (!pluginData || !String(pluginData).trim()) {
275
+ const dataDir =
276
+ pluginData && String(pluginData).trim() ? String(pluginData).trim() : defaultPluginDataDir();
277
+ if (!dryRun && (!pluginData || !String(pluginData).trim())) {
116
278
  process.env.CLAUDE_PLUGIN_DATA = defaultPluginDataDir();
117
279
  }
118
280
 
119
- const { runSetupWizard } = await import('./wizard.mjs');
120
- await runSetupWizard();
281
+ if (dryRun) {
282
+ logDryRunWizard();
283
+ } else {
284
+ const { runSetupWizard } = await import('./wizard.mjs');
285
+ await runSetupWizard();
286
+ }
121
287
 
122
- maybeStarNudge();
288
+ await prewarmRuntimeDepsBestEffort(dryRun, dataDir);
289
+
290
+ if (!dryRun) maybeStarNudge();
291
+ }
292
+
293
+ async function prewarmRuntimeDepsBestEffort(dryRun = false, dataDirOverride = null) {
294
+ const dataDir = dataDirOverride || process.env.CLAUDE_PLUGIN_DATA || defaultPluginDataDir();
295
+ if (dryRun) {
296
+ console.log(
297
+ `[dry-run] would prewarm runtime deps (bun install into ${join(dataDir, '.deps')})`,
298
+ );
299
+ return;
300
+ }
301
+
302
+ const spinner = createSpinner('Installing runtime dependencies…');
303
+ try {
304
+ const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
305
+ mkdirSync(dataDir, { recursive: true });
306
+
307
+ const { resolveBun, installBunViaNpm } = await import('../scripts/resolve-bun.mjs');
308
+ const { ensureRuntimeDeps, resolveNmWithRequiredDeps } = await import('../scripts/ensure-deps.mjs');
309
+
310
+ let bunPath = resolveBun(pluginRoot);
311
+ if (!bunPath) {
312
+ installBunViaNpm(pluginRoot, { fatal: false });
313
+ bunPath = resolveBun(pluginRoot);
314
+ }
315
+ if (!bunPath) {
316
+ spinner.stop('bun unavailable — will install on first launch', false);
317
+ return;
318
+ }
319
+ const seedNm = resolveNmWithRequiredDeps(pluginRoot) || undefined;
320
+
321
+ const result = ensureRuntimeDeps({
322
+ dataDir,
323
+ pluginRoot,
324
+ bunPath,
325
+ seedNm,
326
+ logPrefix: '[setup]',
327
+ installStdio: 'pipe',
328
+ });
329
+ if (result?.satisfied) {
330
+ spinner.stop('prewarmed (first launch should skip bun install)', true);
331
+ } else {
332
+ spinner.stop(`will install on first launch (${result?.reason || 'prewarm did not complete'})`, false);
333
+ }
334
+ } catch (err) {
335
+ spinner.stop(
336
+ `prewarm skipped — ${err?.message || 'first MCP launch may run bun install'}`,
337
+ false,
338
+ );
339
+ }
123
340
  }
124
341
 
125
342
  // Non-blocking, opt-in star nudge. Only on a real interactive terminal — never
package/setup/launch.mjs CHANGED
File without changes
@@ -0,0 +1,38 @@
1
+ // locate-claude.mjs — shared PATH scan for the Claude Code CLI executable.
2
+
3
+ import { execFileSync } from 'node:child_process';
4
+ import { existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ export function resolveClaudeExecutable() {
8
+ const win32 = process.platform === 'win32';
9
+ try {
10
+ const cmd = win32 ? 'where' : 'which';
11
+ const out = execFileSync(cmd, ['claude'], { encoding: 'utf8', windowsHide: true }).trim();
12
+ const first = out.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
13
+ if (first && existsSync(first)) return first;
14
+ } catch { /* PATH scan */ }
15
+
16
+ const pathSep = win32 ? ';' : ':';
17
+ const dirs = String(process.env.PATH || '').split(pathSep).filter(Boolean);
18
+ const pathext = win32
19
+ ? String(process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';').map((e) => e.toLowerCase())
20
+ : [''];
21
+ const bases = win32 ? ['claude'] : ['claude'];
22
+ for (const dir of dirs) {
23
+ for (const base of bases) {
24
+ if (win32) {
25
+ for (const ext of pathext) {
26
+ const candidate = join(dir, base + ext);
27
+ if (existsSync(candidate)) return candidate;
28
+ }
29
+ const bare = join(dir, base);
30
+ if (existsSync(bare)) return bare;
31
+ } else {
32
+ const candidate = join(dir, base);
33
+ if (existsSync(candidate)) return candidate;
34
+ }
35
+ }
36
+ }
37
+ return null;
38
+ }
@@ -1,63 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  // mixdog-cli.mjs — `mixdog` bin dispatcher: launch Claude Code with the dev
3
- // plugin load flags, or run setup/install on demand.
3
+ // plugin load flags for other args; no args or `install` runInstall().
4
4
 
5
- import { spawn, execFileSync } from 'node:child_process';
6
- import { existsSync } from 'node:fs';
7
- import { join } from 'node:path';
5
+ import { spawn } from 'node:child_process';
8
6
  import { realpathSync } from 'node:fs';
9
7
  import { constants as osConstants } from 'node:os';
10
8
  import { fileURLToPath } from 'node:url';
11
9
  import { DEFAULT_MARKETPLACE, DEFAULT_PLUGIN } from '../src/shared/plugin-paths.mjs';
12
10
  import { runInstall } from './install.mjs';
11
+ import { resolveClaudeExecutable } from './locate-claude.mjs';
12
+
13
+ export { resolveClaudeExecutable } from './locate-claude.mjs';
13
14
 
14
15
  const PLUGIN_LOAD_ARG = `plugin:${DEFAULT_PLUGIN}@${DEFAULT_MARKETPLACE}`;
15
16
  const CLAUDE_PREFIX = ['--dangerously-load-development-channels', PLUGIN_LOAD_ARG];
16
17
 
17
- function isSetupCommand(first) {
18
- return first === 'setup' || first === 'install';
19
- }
20
-
21
18
  export function buildClaudeLaunchArgv(passthrough = []) {
22
19
  return [...CLAUDE_PREFIX, ...passthrough];
23
20
  }
24
21
 
25
- export function resolveClaudeExecutable() {
26
- const win32 = process.platform === 'win32';
27
- try {
28
- const cmd = win32 ? 'where' : 'which';
29
- const out = execFileSync(cmd, ['claude'], { encoding: 'utf8', windowsHide: true }).trim();
30
- const first = out.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
31
- if (first && existsSync(first)) return first;
32
- } catch { /* PATH scan */ }
33
-
34
- const pathSep = win32 ? ';' : ':';
35
- const dirs = String(process.env.PATH || '').split(pathSep).filter(Boolean);
36
- const pathext = win32
37
- ? String(process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';').map((e) => e.toLowerCase())
38
- : [''];
39
- const bases = win32 ? ['claude'] : ['claude'];
40
- for (const dir of dirs) {
41
- for (const base of bases) {
42
- if (win32) {
43
- for (const ext of pathext) {
44
- const candidate = join(dir, base + ext);
45
- if (existsSync(candidate)) return candidate;
46
- }
47
- const bare = join(dir, base);
48
- if (existsSync(bare)) return bare;
49
- } else {
50
- const candidate = join(dir, base);
51
- if (existsSync(candidate)) return candidate;
52
- }
53
- }
54
- }
55
- return null;
56
- }
57
-
58
22
  export async function dispatchMixdogCli(argv = process.argv.slice(2)) {
59
23
  const [first] = argv;
60
- if (isSetupCommand(first)) {
24
+ if (argv.length === 0 || first === 'install') {
61
25
  if (process.env.MIXDOG_CLI_DRY_RUN === '1') {
62
26
  process.stdout.write('mixdog-cli: route=setup\n');
63
27
  return 0;
@@ -7,8 +7,8 @@ import { fileURLToPath } from 'url';
7
7
  import http from 'http';
8
8
  import https from 'https';
9
9
  import { DEFAULT_MAINTENANCE, MAINTENANCE_SLOTS, DEFAULT_PRESETS, getPluginData } from '../src/agent/orchestrator/config.mjs';
10
- import { getOpenAIOAuthModelCatalogError, hasOpenAIOAuthCredentials } from '../src/agent/orchestrator/providers/openai-oauth.mjs';
11
- import { hasAnthropicOAuthCredentials } from '../src/agent/orchestrator/providers/anthropic-oauth.mjs';
10
+ import { getOpenAIOAuthModelCatalogError, hasOpenAIOAuthCredentials, loginOAuth as loginOpenAIOAuth } from '../src/agent/orchestrator/providers/openai-oauth.mjs';
11
+ import { hasAnthropicOAuthCredentials, loginOAuth as loginAnthropicOAuth } from '../src/agent/orchestrator/providers/anthropic-oauth.mjs';
12
12
  import { hasGrokOAuthCredentials, loginOAuth as loginGrokOAuth } from '../src/agent/orchestrator/providers/grok-oauth.mjs';
13
13
  import { resolvePluginData } from '../src/shared/plugin-paths.mjs';
14
14
  import { listSchedules } from '../src/shared/schedules-store.mjs';
@@ -2135,6 +2135,54 @@ async function handleRequest(req, res) {
2135
2135
  return;
2136
2136
  }
2137
2137
 
2138
+ if (req.method === 'POST' && path === '/agent/openai-oauth/login') {
2139
+ if (!isAllowedOrigin(req)) {
2140
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2141
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2142
+ return;
2143
+ }
2144
+ try {
2145
+ const tokens = await loginOpenAIOAuth();
2146
+ if (!tokens?.access_token) {
2147
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2148
+ res.end(JSON.stringify({ ok: false, error: 'login cancelled or timed out' }));
2149
+ return;
2150
+ }
2151
+ dropRuntimeModelCaches();
2152
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2153
+ res.end(JSON.stringify({ ok: true }));
2154
+ } catch (e) {
2155
+ process.stderr.write('[setup] /agent/openai-oauth/login failed: ' + (e?.stack || e?.message || String(e)) + '\n');
2156
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2157
+ res.end(JSON.stringify({ ok: false, error: e?.message || String(e) }));
2158
+ }
2159
+ return;
2160
+ }
2161
+
2162
+ if (req.method === 'POST' && path === '/agent/anthropic-oauth/login') {
2163
+ if (!isAllowedOrigin(req)) {
2164
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2165
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: cross-origin' }));
2166
+ return;
2167
+ }
2168
+ try {
2169
+ const tokens = await loginAnthropicOAuth();
2170
+ if (!tokens?.accessToken) {
2171
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2172
+ res.end(JSON.stringify({ ok: false, error: 'login cancelled or timed out' }));
2173
+ return;
2174
+ }
2175
+ dropRuntimeModelCaches();
2176
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2177
+ res.end(JSON.stringify({ ok: true }));
2178
+ } catch (e) {
2179
+ process.stderr.write('[setup] /agent/anthropic-oauth/login failed: ' + (e?.stack || e?.message || String(e)) + '\n');
2180
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2181
+ res.end(JSON.stringify({ ok: false, error: e?.message || String(e) }));
2182
+ }
2183
+ return;
2184
+ }
2185
+
2138
2186
  if (req.method === 'GET' && path === '/agent/presets') {
2139
2187
  res.writeHead(200, { 'Content-Type': 'application/json' });
2140
2188
  res.end(JSON.stringify({ presets: readAgentPresets() }));