mixdog 0.7.5 → 0.7.7

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 (35) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +18 -0
  4. package/hooks/hooks.json +6 -6
  5. package/hooks/session-start.cjs +73 -2
  6. package/hooks/shim-launcher.cjs +51 -0
  7. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  8. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  9. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  10. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  11. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  12. package/package.json +2 -2
  13. package/scripts/bootstrap.mjs +5 -59
  14. package/scripts/ensure-deps.mjs +259 -0
  15. package/scripts/resolve-bun.mjs +60 -0
  16. package/scripts/run-mcp.mjs +13 -168
  17. package/setup/install.mjs +220 -22
  18. package/setup/launch.mjs +0 -0
  19. package/setup/locate-claude.mjs +38 -0
  20. package/setup/mixdog-cli.mjs +95 -0
  21. package/setup/setup-server.mjs +50 -2
  22. package/setup/setup.html +26 -12
  23. package/setup/tui.mjs +606 -0
  24. package/setup/wizard.mjs +220 -151
  25. package/src/agent/bridge-stall-watchdog.mjs +2 -2
  26. package/src/agent/index.mjs +3 -3
  27. package/src/agent/orchestrator/providers/anthropic-oauth.mjs +139 -0
  28. package/src/agent/orchestrator/providers/openai-oauth.mjs +96 -0
  29. package/src/agent/orchestrator/session/manager.mjs +5 -3
  30. package/src/agent/orchestrator/session/store.mjs +9 -1
  31. package/src/channels/lib/runtime-paths.mjs +112 -74
  32. package/src/memory/index.mjs +30 -7
  33. package/src/memory/lib/pg/supervisor.mjs +12 -12
  34. package/src/shared/atomic-file.mjs +16 -0
  35. package/src/status/aggregator.mjs +3 -3
@@ -0,0 +1,60 @@
1
+ /**
2
+ * resolve-bun.mjs — locate or npm-install the bun binary for mixdog boot/setup.
3
+ */
4
+ import { spawnSync } from 'node:child_process';
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ const isWin = process.platform === 'win32';
9
+
10
+ export function findSystemBun() {
11
+ const cmd = isWin ? 'where.exe' : 'which';
12
+ const r = spawnSync(cmd, ['bun'], { encoding: 'utf8', windowsHide: true });
13
+ if (r.status !== 0 || !r.stdout) return null;
14
+ const lines = r.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
15
+ const pick = isWin
16
+ ? (lines.find((l) => l.toLowerCase().endsWith('.exe')) ?? lines[0])
17
+ : lines[0];
18
+ if (!pick) return null;
19
+ return existsSync(pick) ? pick : null;
20
+ }
21
+
22
+ export function findLocalBun(pluginRoot) {
23
+ const candidates = [
24
+ join(pluginRoot, 'node_modules', '.bin', isWin ? 'bun.exe' : 'bun'),
25
+ join(pluginRoot, 'node_modules', 'bun', 'bin', isWin ? 'bun.exe' : 'bun'),
26
+ ];
27
+ for (const p of candidates) if (existsSync(p)) return p;
28
+ return null;
29
+ }
30
+
31
+ export function resolveBun(pluginRoot) {
32
+ return findSystemBun() ?? findLocalBun(pluginRoot);
33
+ }
34
+
35
+ /**
36
+ * @param {string} pluginRoot
37
+ * @param {{ fatal?: boolean }} [opts] — fatal:true (default) exits the process on failure
38
+ * @returns {boolean} success
39
+ */
40
+ export function installBunViaNpm(pluginRoot, { fatal = true } = {}) {
41
+ process.stderr.write(
42
+ '[bootstrap] bun not found on PATH — installing via npm (npm install --no-save --silent bun)...\n',
43
+ );
44
+ const r = spawnSync('npm', ['install', '--no-save', '--silent', 'bun'], {
45
+ cwd: pluginRoot,
46
+ stdio: 'inherit',
47
+ shell: false,
48
+ windowsHide: true,
49
+ });
50
+ if (r.status !== 0 || r.error) {
51
+ const hint = r.error ? ` (${r.error.message})` : '';
52
+ process.stderr.write(
53
+ `[bootstrap] npm install failed${hint}.\n` +
54
+ '[bootstrap] Please install bun manually: https://bun.sh\n',
55
+ );
56
+ if (fatal) process.exit(1);
57
+ return false;
58
+ }
59
+ return true;
60
+ }
@@ -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,8 +2,8 @@
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 -p mixdog mixdog-install (after the package is published)
6
- // or: node setup/install.mjs (from a checkout)
5
+ // Run via: npx mixdog | mixdog install | mixdog-install (after publish)
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
9
9
  // else that is already there):
@@ -20,20 +20,38 @@ import {
20
20
  mkdirSync,
21
21
  copyFileSync,
22
22
  } from 'node:fs';
23
- import { join } from 'node:path';
23
+ import { join, dirname } from 'node:path';
24
24
  import { homedir } from 'node:os';
25
+ import { realpathSync } from 'node:fs';
26
+ import { fileURLToPath } from 'node:url';
25
27
  import { createInterface } from 'node:readline';
26
- import { spawn } from 'node:child_process';
28
+ import { spawn, spawnSync } from 'node:child_process';
27
29
  import { DEFAULT_MARKETPLACE, DEFAULT_PLUGIN } from '../src/shared/plugin-paths.mjs';
30
+ import { resolveClaudeExecutable } from './locate-claude.mjs';
31
+ import { createSpinner } from './tui.mjs';
28
32
 
29
33
  const MARKETPLACE = DEFAULT_MARKETPLACE;
30
34
  const PLUGIN_REF = `${DEFAULT_PLUGIN}@${DEFAULT_MARKETPLACE}`;
31
35
  const REPO = 'trib-plugin/mixdog'; // github owner/repo
32
36
  const REPO_URL = 'https://github.com/trib-plugin/mixdog';
33
37
 
38
+ /** Claude config root — matches Claude Code (settings + plugins tree). */
39
+ export function claudeConfigBaseDir() {
40
+ return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
41
+ }
42
+
34
43
  // Claude Code honours CLAUDE_CONFIG_DIR; otherwise the user scope is ~/.claude.
35
44
  function settingsDir() {
36
- return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
45
+ return claudeConfigBaseDir();
46
+ }
47
+
48
+ export function defaultPluginDataDir() {
49
+ return join(
50
+ claudeConfigBaseDir(),
51
+ 'plugins',
52
+ 'data',
53
+ `${DEFAULT_PLUGIN}-${MARKETPLACE}`,
54
+ );
37
55
  }
38
56
 
39
57
  function loadSettings(file) {
@@ -48,7 +66,7 @@ function isPlainObject(value) {
48
66
  return value !== null && typeof value === 'object' && !Array.isArray(value);
49
67
  }
50
68
 
51
- function registerPluginInSettings() {
69
+ function registerPluginInSettings(dryRun = false) {
52
70
  const dir = settingsDir();
53
71
  const file = join(dir, 'settings.json');
54
72
 
@@ -74,6 +92,15 @@ function registerPluginInSettings() {
74
92
  const already = settings.enabledPlugins[PLUGIN_REF] === true;
75
93
  settings.enabledPlugins[PLUGIN_REF] = true;
76
94
 
95
+ if (dryRun) {
96
+ console.log(`[dry-run] would register mixdog in ${file}`);
97
+ console.log(`[dry-run] marketplace "${MARKETPLACE}" → github:${REPO}`);
98
+ console.log(
99
+ `[dry-run] enabled plugin "${PLUGIN_REF}"${already ? ' (was already enabled)' : ''}`,
100
+ );
101
+ return;
102
+ }
103
+
77
104
  // Back up an existing file before the first write; create the dir otherwise.
78
105
  if (existsSync(file)) {
79
106
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -91,22 +118,179 @@ function registerPluginInSettings() {
91
118
  console.log(`\nNext: restart Claude Code (or run /reload-plugins). mixdog loads automatically.`);
92
119
  }
93
120
 
94
- async function main() {
95
- registerPluginInSettings();
121
+ const CLAUDE_SETUP_GUIDANCE =
122
+ 'Install Claude Code first: https://code.claude.com/docs/en/setup';
123
+
124
+ function officialClaudeInstallerCommandDescription() {
125
+ if (process.platform === 'win32') {
126
+ return 'powershell -NoProfile -Command "irm https://claude.ai/install.ps1 | iex"';
127
+ }
128
+ return 'bash -c "curl -fsSL https://claude.ai/install.sh | bash"';
129
+ }
130
+
131
+ function runOfficialClaudeInstaller() {
132
+ if (process.platform === 'win32') {
133
+ spawnSync(
134
+ 'powershell.exe',
135
+ ['-NoProfile', '-Command', 'irm https://claude.ai/install.ps1 | iex'],
136
+ { stdio: 'inherit', windowsHide: true },
137
+ );
138
+ } else {
139
+ spawnSync('bash', ['-c', 'curl -fsSL https://claude.ai/install.sh | bash'], {
140
+ stdio: 'inherit',
141
+ });
142
+ }
143
+ }
144
+
145
+ function askInstallClaude() {
146
+ return new Promise((resolve) => {
147
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
148
+ rl.question(
149
+ 'Claude Code CLI not found. Install it now with the official installer? (y/N) ',
150
+ (answer) => {
151
+ rl.close();
152
+ const a = answer.trim().toLowerCase();
153
+ resolve(a === 'y' || a === 'yes');
154
+ },
155
+ );
156
+ });
157
+ }
158
+
159
+ async function ensureClaudeInstalled(dryRun = false) {
160
+ const claudePath = resolveClaudeExecutable();
161
+ if (claudePath) {
162
+ if (dryRun) console.log(`[dry-run] Claude Code detected at ${claudePath}`);
163
+ return;
164
+ }
165
+
166
+ if (dryRun) {
167
+ const cmd = officialClaudeInstallerCommandDescription();
168
+ console.log(
169
+ `[dry-run] Claude Code NOT found — would prompt to install via the official installer (${cmd})`,
170
+ );
171
+ return;
172
+ }
173
+
174
+ const interactive = process.stdin.isTTY && !process.env.CI;
175
+
176
+ if (!interactive) {
177
+ console.log(CLAUDE_SETUP_GUIDANCE);
178
+ return;
179
+ }
180
+
181
+ const yes = await askInstallClaude();
182
+ if (yes) {
183
+ runOfficialClaudeInstaller();
184
+ if (resolveClaudeExecutable()) {
185
+ console.log('✓ Claude Code detected.');
186
+ return;
187
+ }
188
+ console.log(
189
+ "✓ Claude Code installed — open a NEW terminal so 'claude' is on PATH, then start Claude Code (mixdog is being registered now).",
190
+ );
191
+ return;
192
+ }
193
+
194
+ console.log(CLAUDE_SETUP_GUIDANCE);
195
+ }
196
+
197
+ const WIZARD_STEP_LABELS = [
198
+ 'Address form',
199
+ 'Discord bot token',
200
+ 'Voice transcription',
201
+ 'Webhook receiver',
202
+ 'Provider API keys',
203
+ 'Model presets',
204
+ 'Role → preset mapping',
205
+ 'Search backend',
206
+ 'Explorer maintenance preset',
207
+ ];
208
+
209
+ function logDryRunWizard() {
210
+ console.log('[dry-run] would run the setup wizard (9 steps):');
211
+ for (let i = 0; i < WIZARD_STEP_LABELS.length; i += 1) {
212
+ console.log(`[dry-run] ${i + 1}. ${WIZARD_STEP_LABELS[i]}`);
213
+ }
214
+ }
215
+
216
+ export async function runInstall() {
217
+ const dryRun =
218
+ process.argv.includes('--dry-run') || process.env.MIXDOG_SETUP_DRY_RUN === '1';
219
+
220
+ if (dryRun) {
221
+ console.log('[dry-run] mixdog install preview — no files, prompts, or installs');
222
+ }
223
+
224
+ await ensureClaudeInstalled(dryRun);
225
+ registerPluginInSettings(dryRun);
96
226
 
97
227
  // npx / node setup/install.mjs runs outside Claude Code — config.mjs needs a data dir.
98
- process.env.CLAUDE_PLUGIN_DATA = join(
99
- homedir(),
100
- '.claude',
101
- 'plugins',
102
- 'data',
103
- 'mixdog-trib-plugin',
104
- );
228
+ const pluginData = process.env.CLAUDE_PLUGIN_DATA;
229
+ const dataDir =
230
+ pluginData && String(pluginData).trim() ? String(pluginData).trim() : defaultPluginDataDir();
231
+ if (!dryRun && (!pluginData || !String(pluginData).trim())) {
232
+ process.env.CLAUDE_PLUGIN_DATA = defaultPluginDataDir();
233
+ }
234
+
235
+ if (dryRun) {
236
+ logDryRunWizard();
237
+ } else {
238
+ const { runSetupWizard } = await import('./wizard.mjs');
239
+ await runSetupWizard();
240
+ }
241
+
242
+ await prewarmRuntimeDepsBestEffort(dryRun, dataDir);
243
+
244
+ if (!dryRun) maybeStarNudge();
245
+ }
246
+
247
+ async function prewarmRuntimeDepsBestEffort(dryRun = false, dataDirOverride = null) {
248
+ const dataDir = dataDirOverride || process.env.CLAUDE_PLUGIN_DATA || defaultPluginDataDir();
249
+ if (dryRun) {
250
+ console.log(
251
+ `[dry-run] would prewarm runtime deps (bun install into ${join(dataDir, '.deps')})`,
252
+ );
253
+ return;
254
+ }
255
+
256
+ const spinner = createSpinner('Installing runtime dependencies…');
257
+ try {
258
+ const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
259
+ mkdirSync(dataDir, { recursive: true });
260
+
261
+ const { resolveBun, installBunViaNpm } = await import('../scripts/resolve-bun.mjs');
262
+ const { ensureRuntimeDeps, resolveNmWithRequiredDeps } = await import('../scripts/ensure-deps.mjs');
105
263
 
106
- const { runSetupWizard } = await import('./wizard.mjs');
107
- await runSetupWizard();
264
+ let bunPath = resolveBun(pluginRoot);
265
+ if (!bunPath) {
266
+ installBunViaNpm(pluginRoot, { fatal: false });
267
+ bunPath = resolveBun(pluginRoot);
268
+ }
269
+ if (!bunPath) {
270
+ spinner.stop('bun unavailable — will install on first launch', false);
271
+ return;
272
+ }
273
+ const seedNm = resolveNmWithRequiredDeps(pluginRoot) || undefined;
108
274
 
109
- maybeStarNudge();
275
+ const result = ensureRuntimeDeps({
276
+ dataDir,
277
+ pluginRoot,
278
+ bunPath,
279
+ seedNm,
280
+ logPrefix: '[setup]',
281
+ installStdio: 'pipe',
282
+ });
283
+ if (result?.satisfied) {
284
+ spinner.stop('prewarmed (first launch should skip bun install)', true);
285
+ } else {
286
+ spinner.stop(`will install on first launch (${result?.reason || 'prewarm did not complete'})`, false);
287
+ }
288
+ } catch (err) {
289
+ spinner.stop(
290
+ `prewarm skipped — ${err?.message || 'first MCP launch may run bun install'}`,
291
+ false,
292
+ );
293
+ }
110
294
  }
111
295
 
112
296
  // Non-blocking, opt-in star nudge. Only on a real interactive terminal — never
@@ -134,7 +318,21 @@ function openRepo() {
134
318
  }
135
319
  }
136
320
 
137
- main().catch((err) => {
138
- console.error(err?.stack || err?.message || String(err));
139
- process.exit(1);
140
- });
321
+ function isInstallerEntry() {
322
+ const entry = process.argv[1];
323
+ if (!entry) return false;
324
+ try {
325
+ const self = realpathSync(fileURLToPath(import.meta.url));
326
+ const invoked = realpathSync(entry);
327
+ return self === invoked;
328
+ } catch {
329
+ return false;
330
+ }
331
+ }
332
+
333
+ if (isInstallerEntry()) {
334
+ runInstall().catch((err) => {
335
+ console.error(err?.stack || err?.message || String(err));
336
+ process.exit(1);
337
+ });
338
+ }
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
+ }