#!/usr/bin/env node
/**
 * Minions CLI — Central AI dev team manager
 *
 * Usage:
 *   minions init [--skip-scan]       Bootstrap ~/.minions/ with default config and agents
 *   minions init --force             Update engine code + add new files (preserves config & customizations)
 *   minions add <project-dir>        Link a project (interactive)
 *   minions remove <project-dir>     Unlink a project
 *   minions list                     List linked projects
 *   minions update [--no-wait]       Update to latest version (--no-wait backgrounds the post-update restart)
 *   minions version                  Show installed and package versions
 *   minions doctor                   Check prerequisites and runtime health
 *   minions restart [--open]         Stop + start engine + dashboard (--open forces a new browser tab)
 *   minions start [--open]           Start engine + dashboard if not already running (no-op when up)
 *   minions stop                     Stop the engine
 *   minions status                   Show engine status
 *   minions pause / resume           Pause/resume dispatching
 *   minions queue                    Show dispatch queue
 *   minions sources                  Show work source status
 *   minions dash                     Start the dashboard
 *   minions work <title> [opts-json] Add a work item
 *   minions spawn <agent> <prompt>   Manually spawn an agent
 *   minions dispatch                 Force a dispatch cycle
 *   minions discover                 Dry-run work discovery
 *   minions cleanup                  Run cleanup manually
 *   minions kill                     Kill active agents and reset to pending
 *   minions complete <dispatch-id>   Mark a dispatch completed
 *   minions config set-cli <R> [--model M]  Persist default runtime/model
 *   minions bridge <subcmd>          Constellation bridge: status|health|enable|disable
 *   minions plan <file|text> [proj]  Run a plan
 *   minions mcp-sync                 Sync MCP servers from ~/.claude.json
 *   minions nuke --confirm           Factory reset runtime state/config
 *   minions uninstall --confirm      Remove Minions and uninstall package
 */

// Phase 9.4 (issue #3035 hardening): self-reexec on Node 22.x without
// --experimental-sqlite so the CLI PARENT process can load `node:sqlite`
// for in-process store reads (status, doctor, etc.). On Node 24+ this is a
// no-op (sqlite is unflagged). Must run BEFORE any `require('engine/*')` —
// engine modules pull in `node:sqlite` transitively via `engine/db`.
(function _ensureSqliteFlagSelfReexec() {
  const major = parseInt(String(process.versions.node).split('.')[0], 10);
  if (!Number.isFinite(major) || major < 22 || major >= 24) return;
  const opts = String(process.env.NODE_OPTIONS || '');
  if (opts.includes('--experimental-sqlite')) return;
  if (process.env.MINIONS_FORCE_JSON === '1') return; // explicit opt-out
  const { spawnSync } = require('child_process');
  const nextOpts = (opts + ' --experimental-sqlite').trim();
  const res = spawnSync(
    process.execPath,
    ['--experimental-sqlite', __filename, ...process.argv.slice(2)],
    { stdio: 'inherit', env: { ...process.env, NODE_OPTIONS: nextOpts } }
  );
  process.exit(res.status == null ? 1 : res.status);
})();

const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawn, spawnSync, execSync } = require('child_process');

const PKG_ROOT = path.resolve(__dirname, '..');
const shared = require(path.join(PKG_ROOT, 'engine', 'shared'));
const { openUrlInBrowser } = shared;
const { waitForRestartHealth, formatRestartHealthError } = require(path.join(PKG_ROOT, 'engine', 'restart-health'));
// Dev-mode dashboard port. `--dev` mode (see argv parser below) retargets
// MINIONS_HOME at PKG_ROOT and routes the dashboard onto this port so a dev
// checkout coexists with a global npm install on the normal port (7331).
const DEFAULT_DEV_DASH_PORT = 7332;

/**
 * Resolve the dashboard port for any CLI command. Precedence (W-mq5nwl9l):
 *   1. Runtime file (engine/dashboard-port.json) — what the live dashboard
 *      ACTUALLY bound to. Wins over the chain so status/dash/restart-health
 *      probes the right port even after an EADDRINUSE auto-fallback.
 *   2. Explicit `--port N` CLI flag if present in `argv`.
 *   3. env MINIONS_DASHBOARD_PORT (dev mode pre-seeds this with devPort).
 *   4. config.json#engine.dashboardPort.
 *   5. shared.DEFAULT_DASHBOARD_PORT (7331).
 *
 * Returns `{port, source, fromRuntimeFile}` so log lines can stay accurate.
 * `fromRuntimeFile=true` means a dashboard wrote it; falsy means we computed
 * the desired port from the chain (used when SPAWNING a fresh dashboard).
 */
function resolveDashboardPort(argv) {
  // Read MINIONS_HOME lazily via env so this helper works at CLI-init time
  // (before the const below is bound) — the const is resolved later.
  const home = process.env.MINIONS_HOME || (typeof MINIONS_HOME !== 'undefined' ? MINIONS_HOME : null);
  const rt = home ? shared.readDashboardPortFile(home) : shared.readDashboardPortFile();
  if (rt && rt.port) return { port: rt.port, source: 'runtime-file', fromRuntimeFile: true };
  let explicit = null;
  if (Array.isArray(argv)) {
    const i = argv.indexOf('--port');
    if (i >= 0 && argv[i + 1]) explicit = argv[i + 1];
  }
  let cfg = null;
  try {
    if (home) cfg = JSON.parse(fs.readFileSync(path.join(home, 'config.json'), 'utf8'));
  } catch { /* config may not exist yet */ }
  const chain = shared.resolveDashboardPort({
    explicit,
    env: process.env.MINIONS_DASHBOARD_PORT,
    config: cfg && cfg.engine && cfg.engine.dashboardPort,
  });
  return { port: chain.port, source: chain.source, fromRuntimeFile: false };
}

/** Returns PIDs (as strings) of processes LISTENING on `port`. Empty on no match
 *  or when the platform tool (netstat/findstr/lsof) is unavailable. */
function getListeningPids(port) {
  try {
    if (process.platform === 'win32') {
      const out = execSync(`netstat -ano | findstr ":${port} " | findstr LISTENING`, { encoding: 'utf8', timeout: 5000, windowsHide: true });
      const pids = new Set();
      for (const line of out.split('\n')) {
        const pid = line.trim().split(/\s+/).pop();
        if (pid && /^\d+$/.test(pid) && pid !== '0' && pid !== String(process.pid)) pids.add(pid);
      }
      return [...pids];
    }
    const out = execSync(`lsof -ti:${port}`, { encoding: 'utf8', timeout: 5000 });
    return out.split('\n').map(s => s.trim()).filter(Boolean);
  } catch { return []; }
}

/** Kill process(es) listening on a given port. Works cross-platform. */
function killByPort(port) {
  const pids = getListeningPids(port);
  if (process.platform === 'win32') {
    for (const pid of pids) try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000, windowsHide: true }); } catch {}
  } else {
    for (const pid of pids) try { process.kill(Number(pid), 'SIGKILL'); } catch {}
  }
}

const isPortListening = (port) => getListeningPids(port).length > 0;

/**
 * Wait until no process is listening on `port`, retrying a kill on each tick
 * for any stragglers that re-appeared (e.g. orphan child the original kill
 * missed, or a process that respawned itself). Returns true when the port is
 * free, false on timeout.
 *
 * Rationale: `taskkill /F` and `SIGKILL` return immediately while the OS does
 * the actual termination asynchronously, and the port doesn't transition to
 * available until the kernel finalises the socket close. Without this wait,
 * the new dashboard race-spawned just after `killByPort` can hit EADDRINUSE
 * and exit silently — the bug pattern that surfaced as "Dashboard failed
 * health check, port=7331 listening=no" while the OLD dashboard PID was
 * still bound to the port.
 */
function waitForPortRelease(port, timeoutMs = 10000, pollMs = 200) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const pids = getListeningPids(port);
    if (pids.length === 0) return { ok: true, waitedMs: Date.now() - start };
    // Retry the kill — covers the case where the original killByPort missed a
    // sibling listener or a new orphan appeared mid-wait.
    killByPort(port);
    const sleepUntil = Date.now() + pollMs;
    while (Date.now() < sleepUntil) { /* spin */ }
  }
  return { ok: false, waitedMs: timeoutMs, stillBound: getListeningPids(port) };
}

/**
 * Wait until `pid` no longer exists. Returns true when the PID is dead, false
 * on timeout. Used to confirm the old engine actually exited before the new
 * one starts writing to control.json — otherwise control.json can flap
 * between "old engine alive (state=stopping)" and "new engine alive
 * (state=running)" and the health check reads the wrong snapshot.
 */
function waitForPidDeath(pid, timeoutMs = 5000, pollMs = 100) {
  if (!pid) return { ok: true, waitedMs: 0 };
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    try {
      // process.kill(pid, 0) throws if the PID doesn't exist; succeeds (no-op)
      // if it does. Works on both Windows and POSIX.
      process.kill(pid, 0);
    } catch {
      return { ok: true, waitedMs: Date.now() - start };
    }
    const sleepUntil = Date.now() + pollMs;
    while (Date.now() < sleepUntil) { /* spin */ }
  }
  return { ok: false, waitedMs: timeoutMs };
}

// Pre-restart beacons can outlive the browser window that produced them
// (closed Edge, locked-screen RDP session) and falsely tell restart to skip
// the auto-open. We wipe the file during restart so the post-restart probe
// only counts beacons that arrive AFTER the new dashboard is up.
function _clearDashboardBrowserState(minionsHome) {
  // Best-effort: a transient EPERM/ENOENT here just means the next probe sees
  // a stale beacon for one cycle, not a restart failure.
  try {
    shared.safeWrite(
      path.join(minionsHome, 'engine', 'dashboard-browser.json'),
      { tabs: {}, updatedAt: new Date().toISOString() },
    );
  } catch {}
}

async function _waitForBrowserReconnect(minionsHome, { afterMs, timeoutMs = 5000, pollMs = 500 } = {}) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    try {
      const fp = path.join(minionsHome, 'engine', 'dashboard-browser.json');
      const state = JSON.parse(fs.readFileSync(fp, 'utf8'));
      const tabs = state && state.tabs ? Object.values(state.tabs) : [];
      if (tabs.some(t => Number(t && t.lastSeen) > afterMs)) return true;
    } catch { /* file may be temporarily missing during write */ }
    await new Promise(r => setTimeout(r, pollMs));
  }
  return false;
}

function _openInBrowser(url) {
  const result = openUrlInBrowser(url);
  if (!result.ok) {
    console.log(`  Could not auto-open browser: ${result.error}`);
    console.log(`  Please open ${url} manually.`);
  }
}

/**
 * Read the engine's recorded PID from engine/control.json. Returns null if
 * the file is missing/corrupt or the PID isn't a positive integer.
 */
function readEnginePid(minionsHome) {
  try {
    const data = JSON.parse(fs.readFileSync(path.join(minionsHome, 'engine', 'control.json'), 'utf8'));
    const pid = Number(data && data.pid);
    return Number.isInteger(pid) && pid > 0 ? pid : null;
  } catch { return null; }
}

/**
 * Force-kill a single process by PID — does NOT recurse into children.
 * This preserves the engine→agent invariant: agents are independent processes
 * spawned as children of the engine, but they must survive engine restarts so
 * they can be re-attached on next start (CLAUDE.md timeouts/liveness section).
 * Tree-kill (`taskkill /T`, `pgrep -P` walk) would orphan in-flight work.
 */
function killPidOnly(pid) {
  if (!pid || pid === process.pid) return;
  try {
    if (process.platform === 'win32') {
      execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000, windowsHide: true });
    } else {
      try { process.kill(pid, 'SIGKILL'); } catch {}
    }
  } catch {}
}

/** Kill minions processes by command-line pattern matching (wmic on Windows, pkill on Unix). */
function killMinionsProcesses(patterns) {
  try {
    if (process.platform === 'win32') {
      // Use PowerShell Get-CimInstance (works on Win11 where wmic is removed)
      let out;
      try {
        out = execSync('powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name=\'node.exe\'\\" | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"', { encoding: 'utf8', timeout: 10000, windowsHide: true });
      } catch {
        // Fallback to wmic for older Windows
        try { out = execSync('wmic process where "name=\'node.exe\'" get processid,commandline /format:csv', { encoding: 'utf8', timeout: 10000, windowsHide: true }); } catch { return; }
      }
      for (const line of out.split('\n')) {
        if (patterns.some(p => line.includes(p))) {
          const pidMatch = line.match(/(\d{2,})/);
          const pid = pidMatch ? pidMatch[1] : null;
          if (pid && pid !== String(process.pid)) killPidOnly(pid);
        }
      }
    } else {
      for (const p of patterns) {
        try { execSync(`pkill -f "${p}" 2>/dev/null`, { timeout: 5000 }); } catch {}
      }
    }
  } catch {}
}

/** Open append-mode FDs under engine/ for detached-process stdio. If the file
 *  can't be opened we fall back to 'ignore' rather than blocking the restart.
 *  Delegates to shared.openAppendLogFd (P-8a4d6f29) so the rotation behavior
 *  (rename to .1 when current file exceeds the managed-spawn log cap) is
 *  uniform across every long-running log Minions writes. */
function _openStdioLog(name) {
  const dir = path.join(MINIONS_HOME, 'engine');
  return shared.openAppendLogFd(name, dir, { fallback: 'ignore' }).fd;
}

/**
 * Node flag args required for SQLite to load. On Node 22.x, `node:sqlite`
 * is gated behind `--experimental-sqlite`; on Node 24+ it is unflagged.
 * Issue #3035: without this, every engine/dashboard/supervisor spawn on
 * Node 22.x loses the SQL store and falls back to the JSON path —
 * /api/work-items returns [] from the aggregate reader because the
 * Phase-9-canonical SQL path is unreachable.
 *
 * Honors NODE_OPTIONS / MINIONS_FORCE_JSON: if the caller already has the
 * flag set (via NODE_OPTIONS) or has opted out of SQL entirely
 * (MINIONS_FORCE_JSON=1), we add nothing — the child inherits the env.
 */
function _sqliteSpawnFlags() {
  if (process.env.MINIONS_FORCE_JSON === '1') return [];
  const major = parseInt(String(process.versions.node).split('.')[0], 10);
  if (!Number.isFinite(major) || major < 22 || major >= 24) return [];
  const nodeOpts = String(process.env.NODE_OPTIONS || '');
  if (nodeOpts.includes('--experimental-sqlite')) return [];
  return ['--experimental-sqlite'];
}

/** Spawn a detached dashboard with self-open suppressed — the CLI decides
 *  when to open a browser based on whether a real tab reconnects post-health.
 *  stdout/stderr land in engine/dashboard-stdio.log so silent startup crashes
 *  leave a postmortem instead of vanishing. The optional `requestedPort` is
 *  forwarded as argv[2] so the dashboard's own port resolver agrees with the
 *  CLI's intent (EADDRINUSE retries still apply inside the dashboard if a
 *  race opens between the CLI's port-free check and the actual bind). */
function spawnDashboard(requestedPort) {
  const env = { ...process.env, MINIONS_NO_AUTO_OPEN: '1' };
  const out = _openStdioLog('dashboard-stdio.log');
  const err = _openStdioLog('dashboard-stdio.log');
  const args = [..._sqliteSpawnFlags(), path.join(MINIONS_HOME, 'dashboard.js')];
  if (requestedPort) args.push(String(requestedPort));
  const proc = spawn(process.execPath, args, {
    cwd: MINIONS_HOME, stdio: ['ignore', out, err], detached: true, windowsHide: true, env
  });
  proc.unref();
  return proc;
}

/** Spawn the external supervisor (engine/supervisor.js). The supervisor
 *  watches BOTH the engine and the dashboard and respawns whichever dies,
 *  unless `engine/stop-intent.json` is present (set by `minions stop`).
 *  Single-instance guard inside supervisor.js means double-spawn during
 *  rapid restart is harmless — the second invocation exits cleanly when it
 *  sees a live PID in supervisor.pid. */
function spawnSupervisor() {
  const out = _openStdioLog('supervisor-stdio.log');
  const err = _openStdioLog('supervisor-stdio.log');
  const proc = spawn(process.execPath, [..._sqliteSpawnFlags(), path.join(MINIONS_HOME, 'engine', 'supervisor.js')], {
    cwd: MINIONS_HOME, stdio: ['ignore', out, err], detached: true, windowsHide: true,
  });
  proc.unref();
  return proc;
}

// Path getter — evaluated lazily inside the helper so MINIONS_HOME, which
// is initialized later in module load, is bound at call time rather than
// at function-definition time.
const _supervisorPidPath = () => path.join(MINIONS_HOME, 'engine', 'supervisor.pid');

/**
 * Spawn engine + dashboard + supervisor, verify health, optionally open browser.
 * Shared by `minions start` and `minions restart` — restart layers a kill phase
 * on top of this, start runs it directly. The IIFE at the end intentionally
 * doesn't await so the parent can return; process.exit(1) fires from inside
 * the IIFE if health verification fails.
 */
function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs }) {
  const engineOut = _openStdioLog('engine-stdio.log');
  const engineErr = _openStdioLog('engine-stdio.log');
  const engineProc = spawn(process.execPath, [..._sqliteSpawnFlags(), path.join(MINIONS_HOME, 'engine.js'), 'start', ...rest], {
    cwd: MINIONS_HOME, stdio: ['ignore', engineOut, engineErr], detached: true, windowsHide: true
  });
  engineProc.unref();
  console.log(`\n  Engine started (PID: ${engineProc.pid})`);
  // The CLI has already killed any stale dashboard + port-released; the
  // resolved chain port is what the new dashboard will TRY first. Forward
  // it via argv so the dashboard's own resolver agrees. EADDRINUSE auto-
  // fallback inside dashboard.js will pick a different port if a race opens.
  const requested = resolveDashboardPort(rest);
  const dashProc = spawnDashboard(requested.port);
  console.log(`  Dashboard started (PID: ${dashProc.pid})`);
  console.log(`  Dashboard: http://localhost:${requested.port}`);
  const supProc = spawnSupervisor();
  console.log(`  Supervisor started (PID: ${supProc.pid})`);
  console.log('  Verifying restart health...');
  void (async () => {
    // Poll the runtime port file so we use the ACTUAL bound port (which may
    // differ from `requested.port` after EADDRINUSE retry). If the dashboard
    // never writes it, fall back to the requested port — restart-health then
    // surfaces a clear failure rather than the CLI silently mis-probing.
    const actualPort = await _waitForDashboardPortFile(MINIONS_HOME, 12000) || requested.port;
    if (actualPort !== requested.port) {
      console.log(`  Dashboard bound to alternate port ${actualPort} (requested ${requested.port} was busy)`);
    }
    const result = await waitForRestartHealth({
      minionsHome: MINIONS_HOME,
      dashboardPid: dashProc.pid,
      dashboardPort: actualPort,
    });
    if (!result.ok) {
      console.error(formatRestartHealthError(result));
      process.exit(1);
    }
    console.log(`  Restart verified: engine PID ${result.engine.pid}; dashboard healthy.`);

    const shouldOpen = forceOpen || !dashWasUp ||
      !(await _waitForBrowserReconnect(MINIONS_HOME, { afterMs: restartStartMs, timeoutMs: 5000 }));
    if (shouldOpen) {
      console.log(`  Opening dashboard in browser...`);
      _openInBrowser(`http://localhost:${actualPort}`);
    }
    console.log('');
  })().catch(err => {
    // Health check failures already process.exit(1) above; reaching here means
    // the browser-open path threw, which is non-fatal — restart already verified.
    console.log(`  Could not open dashboard: ${err.message}`);
  });
}

/** Poll engine/dashboard-port.json until it appears (dashboard wrote it on
 *  listen) or the timeout elapses. Returns the bound port or null on
 *  timeout. Stale files left behind by a crashed dashboard are tolerated —
 *  if the file pre-dates the current spawn we still pick it up; the
 *  subsequent isPortListening probe in waitForRestartHealth will catch a
 *  truly dead dashboard. */
async function _waitForDashboardPortFile(home, timeoutMs = 12000, pollMs = 150) {
  const start = Date.now();
  const startedAt = start;
  while (Date.now() - start < timeoutMs) {
    const rt = shared.readDashboardPortFile(home);
    if (rt && rt.port) {
      // Prefer entries written AFTER we started polling. If the file is
      // older (stale from a previous instance) it'll get rewritten by the
      // new dashboard within the polling window — keep polling until then.
      if (rt.boundAt) {
        const ts = Date.parse(rt.boundAt);
        if (Number.isFinite(ts) && ts >= startedAt - 1000) return rt.port;
      } else {
        return rt.port;
      }
    }
    await new Promise(r => setTimeout(r, pollMs));
  }
  // Final read — even a stale entry is better than nothing if we timed out
  // (the chain fallback in the caller covers a fully-missing file).
  const rtFinal = shared.readDashboardPortFile(home);
  return rtFinal && rtFinal.port ? rtFinal.port : null;
}

/** Clear the stop-intent flag so the supervisor resumes guarding the engine
 *  and dashboard. Called at the top of every start/restart path.
 *  Delegates to engine/shared.js when available so engine.js, dashboard.js,
 *  and the CLI all share one source of truth. Falls back to a direct unlink
 *  during early init when shared.js may not have loaded yet. */
function clearStopIntent() {
  try { shared.clearStopIntent(); }
  catch {
    try { fs.unlinkSync(path.join(MINIONS_HOME, 'engine', 'stop-intent.json')); } catch {}
  }
}

/** Write the stop-intent flag so the supervisor stands down instead of
 *  respawning the engine/dashboard we're about to kill. Called at the top of
 *  every stop/uninstall path. */
function writeStopIntent(source) {
  try { shared.writeStopIntent(source || 'minions cli'); }
  catch {
    try {
      fs.writeFileSync(path.join(MINIONS_HOME, 'engine', 'stop-intent.json'), JSON.stringify({
        stoppedAt: new Date().toISOString(),
        source: source || 'minions cli',
      }, null, 2));
    } catch { /* best effort */ }
  }
}

/** Kill the supervisor process if its PID file points at a live process.
 *  Used by `minions stop` and `minions restart` after writing stop-intent. */
function killSupervisor() {
  try {
    const pid = Number(fs.readFileSync(_supervisorPidPath(), 'utf8').trim());
    if (!pid || pid === process.pid) return;
    if (process.platform === 'win32') {
      try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000, windowsHide: true }); } catch {}
    } else {
      try { process.kill(pid, 'SIGTERM'); } catch {}
    }
    try { fs.unlinkSync(_supervisorPidPath()); } catch {}
  } catch { /* no PID file — supervisor not running */ }
}

const LEGACY_DEFAULT_SQUAD_HOME = path.join(os.homedir(), '.squad');
const LEGACY_ROOT_POINTER_PATH = path.join(os.homedir(), '.squad-root');

function isInstalledRoot(dir) {
  if (!dir) return false;
  return fs.existsSync(path.join(dir, 'engine.js')) &&
    fs.existsSync(path.join(dir, 'dashboard.js')) &&
    fs.existsSync(path.join(dir, 'minions.js'));
}

function isLegacyInstalledRoot(dir) {
  if (!dir) return false;
  return fs.existsSync(path.join(dir, 'engine.js')) &&
    fs.existsSync(path.join(dir, 'dashboard.js')) &&
    fs.existsSync(path.join(dir, 'squad.js'));
}

function readLegacyRootPointer() {
  try {
    const p = fs.readFileSync(LEGACY_ROOT_POINTER_PATH, 'utf8').trim();
    return p ? path.resolve(p) : null;
  } catch { return null; }
}

function saveRootPointer(root) {
  shared.saveMinionsRootPointer(root);
}

function findLegacySquadRoot() {
  const candidates = [
    process.env.SQUAD_HOME ? path.resolve(process.env.SQUAD_HOME) : null,
    path.join(process.cwd(), '.squad'),
    readLegacyRootPointer(),
    LEGACY_DEFAULT_SQUAD_HOME,
  ].filter(Boolean);
  for (const c of candidates) {
    if (isLegacyInstalledRoot(c) || isInstalledRoot(c)) return c;
  }
  return null;
}

function copyLegacyTree(src, dest) {
  fs.mkdirSync(dest, { recursive: true });
  const entries = fs.readdirSync(src, { withFileTypes: true });
  for (const entry of entries) {
    const srcPath = path.join(src, entry.name);
    const destPath = path.join(dest, entry.name);
    if (entry.isDirectory()) {
      copyLegacyTree(srcPath, destPath);
      continue;
    }
    if (!fs.existsSync(destPath)) {
      fs.copyFileSync(srcPath, destPath);
    }
  }
}

function migrateLegacyInstallIfNeeded(targetHome) {
  if (isInstalledRoot(targetHome)) return null;
  const legacyRoot = findLegacySquadRoot();
  if (!legacyRoot) return null;
  const src = path.resolve(legacyRoot);
  const dest = path.resolve(targetHome);
  if (src === dest) return null;

  const targetHasFiles = fs.existsSync(dest) && fs.readdirSync(dest).length > 0;
  if (targetHasFiles && !isInstalledRoot(dest)) return null;

  copyLegacyTree(src, dest);

  const legacyCli = path.join(dest, 'squad.js');
  const newCli = path.join(dest, 'minions.js');
  if (fs.existsSync(legacyCli) && !fs.existsSync(newCli)) fs.renameSync(legacyCli, newCli);

  const legacyBin = path.join(dest, 'bin', 'squad.js');
  const newBin = path.join(dest, 'bin', 'minions.js');
  if (fs.existsSync(legacyBin) && !fs.existsSync(newBin)) fs.renameSync(legacyBin, newBin);

  const legacyVersion = path.join(dest, '.squad-version');
  const newVersion = path.join(dest, '.minions-version');
  if (fs.existsSync(legacyVersion) && !fs.existsSync(newVersion)) fs.renameSync(legacyVersion, newVersion);

  saveRootPointer(dest);
  try { if (fs.existsSync(LEGACY_ROOT_POINTER_PATH)) fs.unlinkSync(LEGACY_ROOT_POINTER_PATH); } catch {}

  const migrationLog = path.join(dest, 'migration.log');
  const line = `${new Date().toISOString()} migrated legacy install ${src} -> ${dest}\n`;
  try { fs.appendFileSync(migrationLog, line); } catch {}

  return { from: src, to: dest };
}

function resolveMinionsHome(forInit = false) {
  return shared.resolveMinionsHome(forInit);
}

// `--dev` runs the in-place checkout AS the runtime root, on a separate
// dashboard port (default 7332) so it can coexist with a global npm install on
// 7331. Only valid when bin/minions.js sits inside a git checkout — the
// `.git` probe stops the global shim (whose PKG_ROOT lives under
// node_modules/) from accidentally activating dev mode.
//
// We strip --dev / --dev-port <n> from argv BEFORE the cmd/rest split so the
// flag can appear in any position (`minions --dev help`, `minions help --dev`,
// `minions --dev start --foo` all work). Otherwise `--dev` as the first arg
// would be misread as the subcommand.
const { cmd, rest, devMode, devPort } = (() => {
  const raw = process.argv.slice(2);
  const out = [];
  let dev = process.env.MINIONS_DEV === '1';
  let port = null;
  for (let i = 0; i < raw.length; i++) {
    const arg = raw[i];
    if (arg === '--dev') { dev = true; continue; }
    if (arg === '--dev-port') {
      const next = raw[i + 1];
      const n = Number(next);
      if (!Number.isInteger(n) || n <= 0 || n > 65535) {
        console.error(`\n  --dev-port requires an integer 1-65535, got: ${next || '(missing)'}\n`);
        process.exit(2);
      }
      port = n; dev = true; i++; continue;
    }
    if (arg && arg.startsWith('--dev-port=')) {
      const n = Number(arg.slice('--dev-port='.length));
      if (!Number.isInteger(n) || n <= 0 || n > 65535) {
        console.error(`\n  --dev-port=<n> requires an integer 1-65535, got: ${arg}\n`);
        process.exit(2);
      }
      port = n; dev = true; continue;
    }
    out.push(arg);
  }
  if (dev && port == null) {
    const envPort = Number(process.env.MINIONS_DEV_PORT);
    if (Number.isInteger(envPort) && envPort > 0 && envPort <= 65535) port = envPort;
  }
  const [firstCmd, ...restArgs] = out;
  return { cmd: firstCmd, rest: restArgs, devMode: dev, devPort: port || DEFAULT_DEV_DASH_PORT };
})();
let force = rest.includes('--force');
const skipScan = rest.includes('--skip-scan');
const skipStart = rest.includes('--skip-start') || rest.includes('--no-start');
// `--open` (or env MINIONS_FORCE_OPEN=1) bypasses the recent-browser-tab heuristic
// in restart/init and forces the new dashboard to auto-open a browser tab. Useful
// when the heuristic mis-fires (e.g. you closed your dashboard tab seconds before
// running restart and its final heartbeat was still inside the 45s window).
const forceOpen = rest.includes('--open') || process.env.MINIONS_FORCE_OPEN === '1';
// When --dev is set, PKG_ROOT MUST be a git checkout. Without the .git probe a
// user who typed `minions --dev` from any directory would silently retarget the
// globally-installed package as its own dev workspace.
if (devMode && !fs.existsSync(path.join(PKG_ROOT, '.git'))) {
  console.error(`
  --dev requires running from a git checkout of minions.
  This bin/minions.js lives under: ${PKG_ROOT}
  No .git directory was found there — refusing to retarget runtime files.

  To use dev mode, clone the repo and run from inside it:
    git clone https://github.com/opg-microsoft/minions.git
    cd minions && node bin/minions.js --dev restart
`);
  process.exit(2);
}

const MINIONS_HOME = devMode ? PKG_ROOT : resolveMinionsHome(cmd === 'init');
process.env.MINIONS_HOME = MINIONS_HOME;
// When --dev is set, force the dashboard chain onto the dev port (7332 by
// default or --dev-port N) so a dev checkout coexists with a global install
// on 7331. We seed MINIONS_DASHBOARD_PORT so resolveDashboardPort()'s env
// step treats devPort as the fallback while still respecting an existing dev
// runtime-file at PKG_ROOT/engine/dashboard-port.json (runtime-file wins).
if (devMode) process.env.MINIONS_DASHBOARD_PORT = String(devPort);
// Effective dashboard port for this CLI invocation. Honors the W-mq5nwl9l
// chain (runtime-file → --port → env → config → DEFAULT_DASHBOARD_PORT/7331);
// --dev overrides via the env seed above. Consumed by the watchdog tick
// callsite below and propagated to children via MINIONS_PORT.
// Named DASHBOARD_PORT (not the deprecated DASH-underscore-PORT identifier) so
// the W-mq5nwl9l source-inspection guard in
// test/unit/dashboard-port-resolution.test.js keeps holding: that test pins
// that no static `\bDASH_PORT\b` survives, because anything other than the
// dynamic chain re-introduces the 7331 bug W-mq5nwl9l was opened to fix.
const DASHBOARD_PORT = resolveDashboardPort(rest).port;
// Propagate the port to every child (engine, dashboard, delegated subcommands).
// engine/cli.js status reads MINIONS_PORT; dashboard.js reads PORT. We export
// MINIONS_PORT for the engine + watch-actions / pipeline / managed-spawn
// loopback callers that already honor it, and pass PORT explicitly when
// spawning dashboard.js.
process.env.MINIONS_PORT = String(DASHBOARD_PORT);
const POST_UPDATE_INIT_TIMEOUT_MS = 120000;
const POST_UPDATE_RESTART_TIMEOUT_MS = 60000;

function isSubpath(parent, child) {
  const rel = path.relative(path.resolve(parent), path.resolve(child));
  return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
}

// ─── Version tracking ───────────────────────────────────────────────────────

function getPkgVersion() {
  try { return require(path.join(PKG_ROOT, 'package.json')).version; } catch { return '0.0.0'; }
}

function getInstalledVersion() {
  try {
    const vFile = path.join(MINIONS_HOME, '.minions-version');
    return fs.readFileSync(vFile, 'utf8').trim();
  } catch { return null; }
}

function saveInstalledVersion(version) {
  fs.writeFileSync(path.join(MINIONS_HOME, '.minions-version'), version);
  // Persist source commit so dashboard can detect repo-based installs
  try {
    const commit = execSync('git rev-parse --short HEAD', { cwd: PKG_ROOT, encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
    if (commit) fs.writeFileSync(path.join(MINIONS_HOME, '.minions-commit'), commit);
  } catch {}
}

// ─── Init / Upgrade ─────────────────────────────────────────────────────────

function init() {
  // Safety guard: avoid recursive copy if user HOME is inside package root.
  // This can happen in tests or unusual shell setups.
  if (isSubpath(PKG_ROOT, MINIONS_HOME)) {
    console.error(`\n  ERROR: Refusing to initialize Minions home inside package directory.`);
    console.error(`  Package root: ${PKG_ROOT}`);
    console.error(`  Minions home:   ${MINIONS_HOME}`);
    console.error('  Set HOME/USERPROFILE to a location outside this repo and run `minions init` again.\n');
    process.exit(1);
  }

  const migration = migrateLegacyInstallIfNeeded(MINIONS_HOME);
  if (migration) {
    console.log(`\n  Migrated legacy Squad install:`);
    console.log(`  ${migration.from} → ${migration.to}`);
  }

  const isUpgrade = fs.existsSync(path.join(MINIONS_HOME, 'engine.js'));
  const pkgVersion = getPkgVersion();
  const installedVersion = getInstalledVersion();

  if (isUpgrade && !force && !migration) {
    console.log(`\n  Minions is installed at ${MINIONS_HOME}`);
    if (installedVersion) console.log(`  Installed version: ${installedVersion}`);
    console.log(`  Package version:   ${pkgVersion}`);
    console.log('\n  To upgrade: minions init --force\n');
    return;
  }

  if (isUpgrade) {
    console.log(`\n  Upgrading Minions at ${MINIONS_HOME}`);
    if (installedVersion) console.log(`  ${installedVersion} → ${pkgVersion}`);
    else console.log(`  → ${pkgVersion}`);
  } else {
    console.log(`\n  Bootstrapping Minions to ${MINIONS_HOME}...`);
  }

  fs.mkdirSync(MINIONS_HOME, { recursive: true });

  // Track what we do for the summary
  const actions = { created: [], updated: [], skipped: [] };

  // Files/dirs to never copy from the package
  const excludeTop = new Set([
    'bin', 'node_modules', '.git', '.claude', 'package-lock.json',
    '.npmignore', '.gitignore', '.github',
  ]);

  // Files that are always overwritten (engine code + version metadata)
  const alwaysUpdate = (name) =>
    name.endsWith('.js') || name.endsWith('.html') || name === 'package.json';

  // Files that should be added if missing but never overwritten (user customizations)
  const neverOverwrite = (name) =>
    name === 'config.json';

  // Copy with smart merge logic
  copyDir(PKG_ROOT, MINIONS_HOME, excludeTop, alwaysUpdate, neverOverwrite, isUpgrade, actions);

  // Create config from template if it doesn't exist
  const configPath = path.join(MINIONS_HOME, 'config.json');
  if (!fs.existsSync(configPath)) {
    const tmpl = path.join(MINIONS_HOME, 'config.template.json');
    if (fs.existsSync(tmpl)) {
      fs.copyFileSync(tmpl, configPath);
      actions.created.push('config.json');
    }
  }

  // Ensure runtime directories exist
  const dirs = ['engine', 'notes/inbox', 'notes/archive', 'identity', 'plans', 'knowledge'];
  for (const d of dirs) {
    fs.mkdirSync(path.join(MINIONS_HOME, d), { recursive: true });
  }

  // Run minions.js init to populate config with defaults (agents, engine settings)
  const initArgs = ['init'];
  if (isUpgrade || skipScan) initArgs.push('--skip-scan');
  execSync(`node "${path.join(MINIONS_HOME, 'minions.js')}" ${initArgs.join(' ')}`, { stdio: 'inherit' });

  // Save version
  saveInstalledVersion(pkgVersion);
  saveRootPointer(MINIONS_HOME);

  // Generate install ID on fresh init (tells dashboard to clear stale browser state)
  const installIdPath = path.join(MINIONS_HOME, '.install-id');
  if (!isUpgrade || !fs.existsSync(installIdPath)) {
    const crypto = require('crypto');
    fs.writeFileSync(installIdPath, crypto.randomBytes(8).toString('hex'));
  }

  console.log('');

  // Show changelog for upgrades
  if (isUpgrade && installedVersion && installedVersion !== pkgVersion) {
    showChangelog(installedVersion);
  }

  // Run preflight checks (warn only — don't block init).
  // includeAllRegistered: probe every registered runtime adapter (claude AND
  // copilot today) so the user sees availability of both, even if their
  // current config only references one. Missing optional runtimes surface as
  // warns, not failures.
  try {
    const { runPreflight, printPreflight } = require(path.join(MINIONS_HOME, 'engine', 'preflight'));
    let preflightConfig = null;
    try { preflightConfig = JSON.parse(fs.readFileSync(path.join(MINIONS_HOME, 'config.json'), 'utf8')); }
    catch { /* config may not exist on first init — fine, preflight handles null */ }
    const { results } = runPreflight({ config: preflightConfig, includeAllRegistered: true });
    printPreflight(results, { label: 'Preflight checks' });
  } catch {}

  // Update flow passes --skip-start so it can perform a single visible restart afterwards.
  if (isUpgrade && skipStart) return;

  // Auto-start on fresh install; direct force-upgrade restarts automatically.
  const upgradeRequested = resolveDashboardPort([]);
  const dashWasUp = isPortListening(upgradeRequested.port);
  const restartStartMs = Date.now();
  if (isUpgrade) {
    // Pre-write stop-intent so the supervisor doesn't race-respawn the
    // engine/dashboard we're about to kill for upgrade.
    writeStopIntent('minions upgrade');
    killSupervisor();
    try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME, timeout: 10000, windowsHide: true }); } catch {}
    // Free the dashboard port too — without this the new dashboard EADDRINUSE-dies
    // silently and the user keeps running stale code from the old dashboard process.
    killByPort(upgradeRequested.port);
    // Wipe the runtime port file so the new dashboard's freshly-written
    // beacon is the source of truth (avoids polling-back a stale entry).
    shared.clearDashboardPortFile(MINIONS_HOME);
    // Clear AFTER kill so the old dashboard can't repopulate during shutdown.
    _clearDashboardBrowserState(MINIONS_HOME);
  }
  // Clear stop-intent so the supervisor we're about to spawn (and any
  // already-running one whose PID file survived) resumes guarding.
  clearStopIntent();
  console.log(isUpgrade
    ? `\n  Upgrade complete (${pkgVersion}). Restarting engine and dashboard...\n`
    : '\n  Starting engine and dashboard...\n');
  const engineProc = spawn(process.execPath, [..._sqliteSpawnFlags(), path.join(MINIONS_HOME, 'engine.js'), 'start'], {
    cwd: MINIONS_HOME, stdio: 'ignore', detached: true, windowsHide: true
  });
  engineProc.unref();
  console.log(`  Engine started (PID: ${engineProc.pid})`);

  const dashProc = spawnDashboard(upgradeRequested.port);
  console.log(`  Dashboard started (PID: ${dashProc.pid})`);
  console.log(`  Dashboard: http://localhost:${upgradeRequested.port}`);

  const supProc = spawnSupervisor();
  console.log(`  Supervisor started (PID: ${supProc.pid})`);

  void (async () => {
    const actualPort = await _waitForDashboardPortFile(MINIONS_HOME, 8000) || upgradeRequested.port;
    const shouldOpen = forceOpen || !dashWasUp ||
      !(await _waitForBrowserReconnect(MINIONS_HOME, { afterMs: restartStartMs, timeoutMs: 5000 }));
    if (shouldOpen) {
      console.log(`  Opening dashboard in browser...`);
      _openInBrowser(`http://localhost:${actualPort}`);
    }
  })().catch(err => {
    console.log(`  Could not open dashboard: ${err.message}`);
  });

  // Next steps guidance
  console.log(`
  Next steps:
    minions work "Explore the codebase"   Give your first task
    minions status                         Check engine state
    minions dash                           Open the dashboard
    minions doctor                         Verify everything is working
    minions --help                         See all commands
`);
}

function copyDir(src, dest, excludeTop, alwaysUpdate, neverOverwrite, isUpgrade, actions, relPath = '') {
  const entries = fs.readdirSync(src, { withFileTypes: true });
  for (const entry of entries) {
    if (excludeTop.size > 0 && excludeTop.has(entry.name)) continue;
    const srcPath = path.join(src, entry.name);
    const destPath = path.join(dest, entry.name);
    const rel = relPath ? `${relPath}/${entry.name}` : entry.name;

    if (entry.isDirectory()) {
      fs.mkdirSync(destPath, { recursive: true });
      // Don't pass top-level excludes to subdirectories
      copyDir(srcPath, destPath, new Set(), alwaysUpdate, neverOverwrite, isUpgrade, actions, rel);
    } else {
      const exists = fs.existsSync(destPath);

      if (!exists) {
        // New file — always copy
        fs.copyFileSync(srcPath, destPath);
        actions.created.push(rel);
      } else if (neverOverwrite(entry.name)) {
        // User config — never overwrite
        actions.skipped.push(rel);
      } else if (alwaysUpdate(entry.name)) {
        // Engine code — always overwrite
        const srcContent = fs.readFileSync(srcPath);
        const destContent = fs.readFileSync(destPath);
        if (!srcContent.equals(destContent)) {
          fs.copyFileSync(srcPath, destPath);
          actions.updated.push(rel);
        }
      } else if (force) {
        // --force: overwrite .md, .json (except config), templates
        const srcContent = fs.readFileSync(srcPath);
        const destContent = fs.readFileSync(destPath);
        if (!srcContent.equals(destContent)) {
          fs.copyFileSync(srcPath, destPath);
          actions.updated.push(rel);
        }
      } else {
        // Default: don't overwrite user files
        actions.skipped.push(rel);
      }
    }
  }
}

function showChangelog(fromVersion) {
  const changelogPath = path.join(MINIONS_HOME, 'CHANGELOG.md');
  if (!fs.existsSync(changelogPath)) return;

  const content = fs.readFileSync(changelogPath, 'utf8');
  // Show a brief hint rather than dumping the whole changelog
  console.log('\n  What\'s new: see CHANGELOG.md or run:');
  console.log(`    cat ${changelogPath}\n`);
}

function formatPackageCliCommand(args) {
  const initScript = path.join(PKG_ROOT, 'bin', 'minions.js');
  return `node "${initScript}" ${args.join(' ')}`;
}

function runPackageCli(args, timeout) {
  const initScript = path.join(PKG_ROOT, 'bin', 'minions.js');
  return spawnSync(process.execPath, [initScript, ...args], {
    cwd: process.cwd(),
    env: { ...process.env, MINIONS_HOME },
    stdio: 'inherit',
    timeout,
    windowsHide: true,
  });
}

function runPostUpdateInit() {
  const args = ['init', '--force', '--skip-start'];
  const result = runPackageCli(args, POST_UPDATE_INIT_TIMEOUT_MS);

  if (!result.error && result.status === 0) return;

  const timedOut = result.error && result.error.code === 'ETIMEDOUT';
  if (timedOut) {
    console.error(`\n  ERROR: Post-update initialization timed out after ${POST_UPDATE_INIT_TIMEOUT_MS / 1000}s.`);
  } else {
    const detail = result.error ? result.error.message : `exit code ${result.status}`;
    console.error(`\n  ERROR: Post-update initialization failed (${detail}).`);
  }
  console.error('  The npm package update completed, but runtime files were not fully synchronized.');
  console.error('  After npm finishes settling, run:');
  console.error(`    ${formatPackageCliCommand(args)}`);
  console.error(`    ${formatPackageCliCommand(['restart'])}\n`);
  process.exit(1);
}

function runPostUpdateRestart() {
  const args = ['restart'];
  const result = runPackageCli(args, POST_UPDATE_RESTART_TIMEOUT_MS);

  if (!result.error && result.status === 0) return;

  const timedOut = result.error && result.error.code === 'ETIMEDOUT';
  if (timedOut) {
    console.error(`\n  ERROR: Post-update restart timed out after ${POST_UPDATE_RESTART_TIMEOUT_MS / 1000}s.`);
  } else {
    const detail = result.error ? result.error.message : `exit code ${result.status}`;
    console.error(`\n  ERROR: Post-update restart failed (${detail}).`);
  }
  console.error('  Runtime files were synchronized, but the engine/dashboard were not restarted.');
  console.error('  Run this command to finish the update without using the Windows command shim:');
  console.error(`    ${formatPackageCliCommand(args)}\n`);
  process.exit(1);
}

// Fire-and-forget variant for `minions update --no-wait`. The npm + init phases
// have already completed synchronously, so package-install failures surface
// normally; only the long-tail engine/dashboard restart + health check is
// backgrounded. stdio is redirected to a log so the user can postmortem if the
// restart fails after the parent has exited.
function runPostUpdateRestartDetached() {
  const initScript = path.join(PKG_ROOT, 'bin', 'minions.js');
  const out = _openStdioLog('update-restart-stdio.log');
  const err = _openStdioLog('update-restart-stdio.log');
  const proc = spawn(process.execPath, [initScript, 'restart'], {
    cwd: process.cwd(),
    env: { ...process.env, MINIONS_HOME },
    stdio: ['ignore', out, err],
    detached: true,
    windowsHide: true,
  });
  proc.unref();
  const logPath = path.join(MINIONS_HOME, 'engine', 'update-restart-stdio.log');
  console.log(`  Restart spawned in background (PID: ${proc.pid}); logs: ${logPath}`);
  console.log(`  Verify with: minions status\n`);
}

// ─── Version command ────────────────────────────────────────────────────────

function showVersion() {
  const pkg = getPkgVersion();
  const installed = getInstalledVersion();
  console.log(`\n  Package version:   ${pkg}`);
  console.log(`  Runtime root:      ${MINIONS_HOME}`);
  if (installed) {
    console.log(`  Installed version: ${installed}`);
    if (installed !== pkg) {
      console.log('\n  Update available! Run: minions update');
    } else {
      console.log('  Up to date.');
    }
  } else {
    console.log('  Not installed yet. Run: minions init');
  }

  // Check npm registry for latest version (best-effort, non-blocking)
  try {
    const latest = execSync('npm view @yemi33/minions version', { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim();
    if (latest && latest !== pkg) {
      console.log(`\n  Latest on npm:     ${latest}`);
      console.log('  To update: minions update');
    }
  } catch {} // offline or npm not available — skip silently

  console.log('');
}

// ─── Delegate: run commands against installed ~/.minions/ ─────────────────────

function ensureInstalled() {
  if (!fs.existsSync(path.join(MINIONS_HOME, 'engine.js'))) {
    console.log('\n  Minions is not installed. Run: minions init\n');
    process.exit(1);
  }
}

function delegate(script, args) {
  ensureInstalled();
  const child = spawn(process.execPath, [..._sqliteSpawnFlags(), path.join(MINIONS_HOME, script), ...args], {
    stdio: 'inherit',
    cwd: MINIONS_HOME,
    env: { ...process.env, MINIONS_HOME },
  });
  child.on('exit', code => process.exit(code || 0));
}

// ─── Command routing ────────────────────────────────────────────────────────

// `start` is intentionally NOT in this set — it has its own handler that
// spawns the full stack (engine + dashboard + supervisor + health verify +
// browser). The internal `engine.js start` subcommand is still reachable for
// callers that want the daemon alone (supervisor.spawnEngine, dashboard's
// in-process engine watchdog) — they invoke engine.js directly, not through
// the CLI.
const engineCmds = new Set([
  'stop', 'status', 'pause', 'resume',
  'queue', 'sources', 'discover', 'dispatch',
  'spawn', 'work', 'cleanup', 'mcp-sync', 'plan',
  'kill', 'complete', 'config', 'pr', 'bridge',
]);

if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
  console.log(`
  Minions — Central AI dev team manager

  Setup:
    minions init                     Bootstrap ~/.minions/ (first time)
    minions update [--no-wait]       Update to latest version (--no-wait backgrounds the restart)
    minions version                  Show installed vs package version
    minions doctor                   Check prerequisites and runtime health
    minions add <project-dir>        Link a project (interactive)
    minions remove <project-dir>     Unlink a project
    minions list                     List linked projects

  Engine:
    minions restart [--open]         Stop + start engine + dashboard (--open forces a new browser tab)
    minions start [--open]           Start engine + dashboard if not already running (idempotent; --open forces a new tab)
    minions stop                     Stop the engine
    minions status                   Show agents, projects, queue
    minions pause / resume           Pause/resume dispatching
    minions dispatch                 Force a dispatch cycle
    minions discover                 Dry-run work discovery
    minions queue                    Show dispatch queue (pending/active/completed)
    minions sources                  Show work source status per project
    minions work <title> [opts]      Add a work item
    minions spawn <agent> <prompt>   Manually spawn an agent
    minions plan <file|text> [proj]  Run a plan
    minions kill                     Kill all active agents and reset to pending
    minions complete <dispatch-id>   Manually mark a dispatch as completed
    minions config set-cli <R> [--model M]
                                      Persist default runtime/model without starting
    minions bridge <subcmd>          Constellation bridge: status|health|enable|disable
    minions mcp-sync                 Sync MCP servers from ~/.claude.json
    minions cleanup                  Clean temp files, worktrees, zombies
    minions pr comment <repo> <n>    Post a marker-prepended PR comment via gh
                                       --agent <id> --kind <k> [--wi <id>] (--body-file <f> | --body <text>)
    minions nuke --confirm           Factory reset (delete state, reset config to defaults)
    minions uninstall --confirm      Remove everything + uninstall npm package

  Dashboard API (HTTP passthrough to localhost:7331):
    minions api <METHOD> <PATH>      Generic API call (any endpoint, see /api/routes)
    minions wi <subcmd>              Work-items: list/show/cancel/retry/delete/archive/reopen/feedback
    minions plans <subcmd>           Plans: list/show/approve/pause/reject/archive/unarchive/regenerate
    minions schedule <subcmd>        Schedules: list/show/add/update/delete/run-now/parse
    minions watch <subcmd>           Watches: list/show/add/update/delete/target-types/action-types
    minions feature <subcmd>         Feature flags: list/toggle
    minions settings <subcmd>        Settings: get [path]/set <path> <value>/reset

  Dashboard:
    minions dash                     Start web dashboard (default :7331)

  Watchdog (out-of-process recovery):
    minions watchdog install [--interval=5]
                                      Register OS scheduler task to probe + heal every N minutes
                                      (Windows Task Scheduler / macOS launchd / Linux systemd --user)
    minions watchdog uninstall       Remove the scheduled task (idempotent)
    minions watchdog status          Show registration + last-run details from the OS scheduler
    minions watchdog tick            One-shot probe + recovery (used by the scheduler; safe to run by hand)
${fs.existsSync(path.join(PKG_ROOT, '.git')) ? `
  Dev mode (this checkout, contributors only):
    minions --dev <cmd>              Run against this checkout instead of ~/.minions/
                                       MINIONS_HOME=<checkout>, dashboard on :7332.
                                       Coexists with the global install on :7331.
    minions --dev --dev-port <n> <cmd>  Override the dev dashboard port.
    npm run dev                      Shortcut for: node bin/minions.js --dev restart
` : ''}
  Runtime root: ${MINIONS_HOME}${devMode ? '  [DEV]' : ''}${devMode ? `
  Dashboard port: ${DASHBOARD_PORT}  [DEV]` : ''}
`);
} else if (cmd === 'init') {
  init();
} else if (cmd === 'update') {
  const noWait = rest.includes('--no-wait') || rest.includes('--detach');
  console.log('\n  Updating Minions...\n');
  // Dev/symlink installs: PKG_ROOT === MINIONS_HOME — npm update is a no-op (symlink already
  // points to the repo), and `minions init --force` would fail (cwd/.minions is inside PKG_ROOT).
  // Just sync the version file and restart.
  const isSymlinkedDevInstall = path.resolve(PKG_ROOT) === path.resolve(MINIONS_HOME);
  if (isSymlinkedDevInstall) {
    saveInstalledVersion(getPkgVersion());
    console.log(`  Version synced to ${getPkgVersion()} (dev/symlink install — pull from git to update code)`);
  } else {
    try {
      execSync('npm install -g @yemi33/minions@latest', { stdio: 'inherit', timeout: 120000 });
    } catch (e) {
      console.error('  npm update failed:', e.message);
      process.exit(1);
    }
    // Equivalent to `minions init --force --skip-start`, but avoids recursing through
    // the global shim while npm is still settling the updated install.
    runPostUpdateInit();
  }
  // Restart engine + dashboard so they pick up the new code. With --no-wait the
  // restart is spawned detached so the caller (e.g. a DevBox maintenance task)
  // can return immediately without holding the parent open for the health poll.
  console.log('\n  Restarting engine and dashboard...\n');
  if (noWait) {
    runPostUpdateRestartDetached();
  } else {
    runPostUpdateRestart();
  }
} else if (cmd === 'version' || cmd === '--version' || cmd === '-v') {
  showVersion();
} else if (cmd === 'add' || cmd === 'remove' || cmd === 'list' || cmd === 'scan') {
  delegate('minions.js', [cmd, ...rest]);
} else if (cmd === 'start') {
  // `minions start` brings up the full Minions stack: engine + dashboard +
  // supervisor + health verification + browser. The internal `engine.js start`
  // subcommand (engine alone, used by supervisor.spawnEngine and dashboard's
  // engine watchdog) is still reachable directly via `node engine.js start`,
  // but the CLI verb no longer falls through to it.
  //
  // Idempotent: if Minions is already up (engine alive + port listening),
  // print status and optionally open the browser. Partial state (one side
  // up, the other down) is a recovery scenario — refuse and direct the
  // user to `minions restart`, which has the kill phase needed to reconcile.
  ensureInstalled();
  const enginePid = readEnginePid(MINIONS_HOME);
  let engineAlive = false;
  if (enginePid) {
    try { process.kill(enginePid, 0); engineAlive = true; } catch { /* dead */ }
  }
  // Probe the runtime port file first (already-bound dashboard) then fall
  // back to the chain. Source distinguishes 'runtime-file' vs default for
  // the partial-state error message.
  const startResolved = resolveDashboardPort(rest);
  const dashUp = isPortListening(startResolved.port);
  if (engineAlive && dashUp) {
    console.log(`\n  Minions is already running (engine PID ${enginePid}; dashboard http://localhost:${startResolved.port}).`);
    if (forceOpen) {
      console.log(`  Opening dashboard in browser...`);
      _openInBrowser(`http://localhost:${startResolved.port}`);
    } else {
      console.log(`  Run \`minions dash\` to open the dashboard, or \`minions start --open\` to force a new browser tab.\n`);
    }
    process.exit(0);
  }
  if (engineAlive || dashUp) {
    const which = engineAlive ? `engine (PID ${enginePid}) is up but dashboard is down` : `dashboard is up but engine is down`;
    console.error(`\n  ERROR: Partial state detected — ${which}.`);
    console.error(`  Run \`minions restart\` to reconcile (it kills survivors before respawning).\n`);
    process.exit(1);
  }
  console.log('\n  Starting Minions (engine + dashboard + supervisor)...');
  spawnFullStackAndVerify({ rest, forceOpen, dashWasUp: false, restartStartMs: Date.now() });
} else if (cmd === 'restart') {
  // `--cli` / `--model` flags forward to `engine.js start` so the runtime
  // fleet flips before the daemon spawns (P-6b3f9c2e AC: works on restart).
  ensureInstalled();
  // For restart we want the port the CURRENT dashboard is bound to (so we
  // kill it on its actual port) — runtime file wins for the kill step. The
  // chain falls in as fallback if no file exists yet (cold start).
  const restartResolved = resolveDashboardPort(rest);
  const dashWasUp = isPortListening(restartResolved.port);
  const restartStartMs = Date.now();
  // Pre-write stop-intent so the supervisor doesn't race-respawn the
  // engine/dashboard we're about to kill. Killed before the engine/dashboard
  // so its 30s tick doesn't fire mid-restart against a half-dead engine.
  writeStopIntent('minions restart');
  killSupervisor();
  // Layered kill — each step is best-effort, so the next still runs if one
  // fails. Goal: the old engine is gone before we spawn a new one, even if
  // PowerShell is unavailable, the engine is hung, or its cmdline doesn't match.
  const oldEnginePid = readEnginePid(MINIONS_HOME);
  try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME, timeout: 10000, windowsHide: true }); } catch {}
  // Force-kill the recorded engine PID (NOT the tree — agent children must
  // survive so the new engine can re-attach them via PID files).
  killPidOnly(oldEnginePid);
  killByPort(restartResolved.port);
  killMinionsProcesses(['engine.js', 'dashboard.js', 'supervisor.js']);
  // Confirm the OS finished the asynchronous termination before we spawn new
  // processes. Without this, `taskkill /F` returns immediately while the
  // kernel is still releasing the dashboard's port; the new dashboard spawned
  // ~10ms later hits EADDRINUSE and exits, producing the "Restart verification
  // failed, port=7331 listening=no" symptom against an orphan PID that's
  // STILL bound to the port a heartbeat later.
  if (oldEnginePid) {
    const engineDead = waitForPidDeath(oldEnginePid, 5000);
    if (!engineDead.ok) {
      console.error(`\n  ERROR: Old engine (PID ${oldEnginePid}) did not exit within 5s after kill.`);
      console.error(`  The new engine cannot safely take over control.json. Aborting restart.`);
      process.exit(1);
    }
  }
  const portFree = waitForPortRelease(restartResolved.port, 10000);
  if (!portFree.ok) {
    console.error(`\n  ERROR: Port ${restartResolved.port} still in use after 10s — killing failed.`);
    console.error(`  Bound by PID(s): ${portFree.stillBound.join(', ')}`);
    console.error(`  Manually free the port: taskkill /F /PID ${portFree.stillBound.join(' /PID ')}`);
    console.error(`  Then retry: minions restart`);
    process.exit(1);
  }
  // Wipe the runtime port file so a stale entry from the old dashboard
  // can't fool the post-spawn poll into reading the old port.
  shared.clearDashboardPortFile(MINIONS_HOME);
  // Clear stale beacons AFTER the kill so the old dashboard's last writes
  // can't repopulate the file in the gap between clear and shutdown.
  _clearDashboardBrowserState(MINIONS_HOME);
  // Clear stop-intent so the freshly-spawned supervisor resumes guarding.
  clearStopIntent();
  spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs });
} else if (cmd === 'nuke') {
  ensureInstalled();
  if (!rest.includes('--confirm')) {
    console.log(`
  Factory reset — kills all processes and deletes all runtime state.

  DELETED:
    - Work items, dispatch queue, PRDs, plans, pipelines
    - Agent history, sessions, output logs
    - Notes, knowledge base, pinned notes
    - Metrics, cooldowns, schedules, meetings
    - Project state (PR tracking, per-project work items)

  RESET to defaults:
    - config.json (engine settings, agents — project links removed)
    - routing.md

  PRESERVED:
    - Agent charters
    - Playbooks

  Run: minions nuke --confirm
`);
    process.exit(0);
  }

  console.log('\n  Minions Factory Reset\n');

  // 1. Kill all processes
  try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME }); } catch {}
  killByPort(resolveDashboardPort([]).port);
  killMinionsProcesses(['engine.js', 'dashboard.js', 'spawn-agent.js']);
  shared.clearDashboardPortFile(MINIONS_HOME);
  console.log('  Killed all processes');

  // 2. Delete runtime state
  const glob = (dir, pattern) => { try { return fs.readdirSync(dir).filter(f => pattern.test(f)).map(f => path.join(dir, f)); } catch { return []; } };
  const rm = (f) => { try { fs.unlinkSync(f); } catch {} };
  const rmDir = (d) => { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} };
  const engineDir = path.join(MINIONS_HOME, 'engine');

  // Engine state
  for (const f of ['dispatch.json', 'control.json', 'log.json', 'metrics.json', 'cooldowns.json', 'schedule-runs.json', 'kb-checkpoint.json', 'cc-session.json', 'doc-sessions.json', 'pipeline-runs.json']) rm(path.join(engineDir, f));
  glob(engineDir, /^pid-.*\.pid$/).forEach(rm);
  rmDir(path.join(engineDir, 'tmp'));

  // Work items + PRs
  rm(path.join(MINIONS_HOME, 'work-items.json'));
  rm(path.join(MINIONS_HOME, 'work-items-archive.json'));
  rm(path.join(MINIONS_HOME, 'pull-requests.json'));

  // Plans + PRDs + Pipelines + Meetings
  rmDir(path.join(MINIONS_HOME, 'plans'));
  rmDir(path.join(MINIONS_HOME, 'prd'));
  rmDir(path.join(MINIONS_HOME, 'pipelines'));
  rmDir(path.join(MINIONS_HOME, 'meetings'));
  fs.mkdirSync(path.join(MINIONS_HOME, 'plans'), { recursive: true });
  fs.mkdirSync(path.join(MINIONS_HOME, 'prd'), { recursive: true });

  // Notes + KB
  rm(path.join(MINIONS_HOME, 'notes.md'));
  rm(path.join(MINIONS_HOME, 'pinned.md'));
  rmDir(path.join(MINIONS_HOME, 'notes'));
  rmDir(path.join(MINIONS_HOME, 'knowledge'));
  fs.mkdirSync(path.join(MINIONS_HOME, 'notes', 'inbox'), { recursive: true });

  // Agent state (preserve charters)
  const agentsDir = path.join(MINIONS_HOME, 'agents');
  try {
    for (const agent of fs.readdirSync(agentsDir)) {
      const agentDir = path.join(agentsDir, agent);
      if (!fs.statSync(agentDir).isDirectory()) continue;
      for (const f of fs.readdirSync(agentDir)) {
        if (f === 'charter.md') continue;
        rm(path.join(agentDir, f));
      }
    }
  } catch {}

  // Projects state
  rmDir(path.join(MINIONS_HOME, 'projects'));
  fs.mkdirSync(path.join(MINIONS_HOME, 'projects'), { recursive: true });

  // 3. Reset config.json and routing.md to defaults
  const tmplPath = path.join(MINIONS_HOME, 'config.template.json');
  const configPath = path.join(MINIONS_HOME, 'config.json');
  if (fs.existsSync(tmplPath)) {
    fs.copyFileSync(tmplPath, configPath);
  } else {
    fs.writeFileSync(configPath, JSON.stringify({ projects: [], engine: {}, claude: {}, agents: {} }, null, 2));
  }
  // Re-run init to populate defaults (agents, engine settings)
  try { execSync(`node "${path.join(MINIONS_HOME, 'minions.js')}" init --skip-scan`, { stdio: 'inherit' }); } catch {}

  console.log('\n  Factory reset complete. Run "minions init" to link projects and start fresh.\n');
} else if (cmd === 'uninstall') {
  if (!rest.includes('--confirm')) {
    console.log(`
  Uninstall Minions — removes EVERYTHING.

  This will:
    1. Kill all running engine, dashboard, and agent processes
    2. Delete the entire ${MINIONS_HOME} directory (all state, config, agents, knowledge)
    3. Remove extracted skills from ~/.claude/skills/ (minions-authored only)
    4. Uninstall the npm package (@yemi33/minions)

  This is irreversible. Your project repos are NOT affected.

  Run: minions uninstall --confirm
`);
    process.exit(0);
  }

  console.log('\n  Uninstalling Minions...\n');

  // 1. Kill all processes
  // Stop-intent FIRST so the supervisor stands down before we kill it. Without
  // this the supervisor could race-respawn the engine/dashboard between our
  // engine-stop and the supervisor kill.
  writeStopIntent('minions uninstall');
  killSupervisor();
  try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME, timeout: 10000 }); } catch {}
  killByPort(resolveDashboardPort([]).port);
  killMinionsProcesses(['engine.js', 'dashboard.js', 'spawn-agent.js', 'supervisor.js']);
  console.log('  Killed all processes');

  // 2. Remove minions-authored skills from ~/.claude/skills/
  try {
    const claudeSkills = path.join(os.homedir(), '.claude', 'skills');
    if (fs.existsSync(claudeSkills)) {
      for (const dir of fs.readdirSync(claudeSkills)) {
        const skillFile = path.join(claudeSkills, dir, 'SKILL.md');
        try {
          const content = fs.readFileSync(skillFile, 'utf8');
          if (content.includes('Auto-extracted skill') || content.includes('author:')) {
            fs.rmSync(path.join(claudeSkills, dir), { recursive: true, force: true });
          }
        } catch {}
      }
      console.log('  Cleaned minions skills from ~/.claude/skills/');
    }
  } catch {}

  // 3. Delete ~/.minions entirely
  if (fs.existsSync(MINIONS_HOME)) {
    fs.rmSync(MINIONS_HOME, { recursive: true, force: true });
    console.log('  Deleted ' + MINIONS_HOME);
  }

  // 4. Uninstall npm package
  console.log('  Uninstalling npm package...');
  try { execSync('npm uninstall -g @yemi33/minions', { stdio: 'inherit', timeout: 60000 }); } catch {}

  console.log('\n  Minions uninstalled. Your project repos were not touched.');
  console.log('\n  To re-install later, run:');
  console.log('    npm install -g @yemi33/minions && minions init\n');
} else if (cmd === 'doctor') {
  ensureInstalled();
  const { doctor } = require(path.join(MINIONS_HOME, 'engine', 'preflight'));
  doctor(MINIONS_HOME).then(ok => process.exit(ok ? 0 : 1));
} else if (cmd === 'watchdog') {
  // External recovery scheduled by the OS. Survives cluster-kill scenarios
  // the in-process supervisor can't (Windows job-object teardown when the
  // parent terminal closes, Linux OOM-killer, cgroup limits, pkill, reboot).
  ensureInstalled();
  const watchdog = require(path.join(MINIONS_HOME, 'engine', 'watchdog'));
  const sub = rest[0];
  const minionsBin = __filename;
  if (sub === 'tick') {
    const shared = require(path.join(MINIONS_HOME, 'engine', 'shared'));
    // Resolve the live dashboard port via the in-file resolver, which honors
    // the runtime beacon (written by dashboard.js after bind), env, and
    // config — so the watchdog probes whatever port the dashboard actually
    // fell back to under EADDRINUSE, not a hard-coded default.
    // See W-mq5nwl9l for the helper contract and W-mq5y1cti for the
    // watchdog-tick beacon hookup.
    watchdog.tick({
      minionsHome: MINIONS_HOME,
      minionsBin,
      dashPort: DASHBOARD_PORT,
      readEnginePid,
      isPortListening,
      isStopIntentSet: shared.isStopIntentSet || (() => false),
    }).then(result => {
      // ALWAYS exit 0 — the scheduler must never see a failure for the
      // recovery we're trying to make boring. The `result` is logged by tick
      // to engine/watchdog-stdio.log; print it for interactive invocations.
      if (process.stdout.isTTY) {
        console.log(JSON.stringify(result, null, 2));
      }
      process.exit(0);
    }).catch(err => {
      // Defense in depth: tick promises never to throw, but if it does,
      // swallow the error and exit 0 so the scheduler keeps ticking.
      try { console.error(`watchdog tick: ${err && err.message || err}`); } catch {}
      process.exit(0);
    });
  } else if (sub === 'install') {
    const intervalArg = rest.find(a => a.startsWith('--interval='));
    const intervalMin = intervalArg ? parseInt(intervalArg.split('=')[1], 10) : undefined;
    try {
      const r = watchdog.install({
        minionsBin,
        minionsHome: MINIONS_HOME,
        intervalMin,
      });
      console.log(`\n  Watchdog installed via ${r.scheduler} (every ${r.intervalMin} min).`);
      if (r.taskName) console.log(`    Task: ${r.taskName}`);
      if (r.launcherPath) console.log(`    Launcher: ${r.launcherPath}`);
      if (r.plistPath) console.log(`    Plist: ${r.plistPath}`);
      if (r.servicePath) console.log(`    Service: ${r.servicePath}`);
      if (r.timerPath) console.log(`    Timer: ${r.timerPath}`);
      if (r.lingerHint) console.log(`    Note: ${r.lingerHint}`);
      console.log(`\n  MINIONS_HOME=${MINIONS_HOME} (baked into scheduler entry — re-run install if it moves)`);
      console.log(`  Logs: ${path.join(MINIONS_HOME, 'engine', 'watchdog-stdio.log')}\n`);
      process.exit(0);
    } catch (err) {
      console.error(`\n  ERROR: ${err && err.message || err}\n`);
      process.exit(1);
    }
  } else if (sub === 'uninstall') {
    try {
      const r = watchdog.uninstall({ minionsHome: MINIONS_HOME });
      console.log(`\n  Watchdog uninstalled from ${r.scheduler}.${r.removed ? '' : ' (already absent — no-op)'}\n`);
      process.exit(0);
    } catch (err) {
      console.error(`\n  ERROR: ${err && err.message || err}\n`);
      process.exit(1);
    }
  } else if (sub === 'status' || !sub) {
    try {
      const r = watchdog.status();
      console.log(`\n  Watchdog (${r.scheduler}): ${r.installed ? 'installed' : 'NOT installed'}`);
      if (r.taskName) console.log(`    Task: ${r.taskName}`);
      if (r.plistPath) console.log(`    Plist: ${r.plistPath}${r.loaded ? ' (loaded)' : ''}`);
      if (r.timerPath) console.log(`    Timer: ${r.timerPath}${r.active ? ' (active)' : ''}`);
      if (r.details) {
        console.log('\n  Scheduler details:');
        console.log(r.details.split('\n').map(l => `    ${l}`).join('\n'));
      }
      console.log('');
      process.exit(r.installed ? 0 : 1);
    } catch (err) {
      console.error(`\n  ERROR: ${err && err.message || err}\n`);
      process.exit(1);
    }
  } else {
    console.error(`\n  Unknown watchdog subcommand: ${sub}`);
    console.error(`  Usage: minions watchdog (install|uninstall|status|tick)\n`);
    process.exit(1);
  }
} else if (cmd === 'dash' || cmd === 'dashboard') {
  ensureInstalled();
  // If dashboard is already running, just open the browser. The runtime
  // port file wins over the resolution chain when the dashboard is up so
  // the probe targets the actually-bound port (which may differ from the
  // default after an EADDRINUSE auto-fallback).
  const dashResolved = resolveDashboardPort(rest);
  const net = require('net');
  const sock = new net.Socket();
  let handled = false;
  sock.setTimeout(1000);
  sock.on('connect', () => {
    sock.destroy();
    if (handled) return;
    handled = true;
    const url = `http://localhost:${dashResolved.port}`;
    console.log(`\n  Dashboard already running: ${url}\n`);
    openUrlInBrowser(url);
  });
  sock.on('error', () => {
    sock.destroy();
    if (handled) return;
    handled = true;
    delegate('dashboard.js', rest);
  });
  sock.on('timeout', () => {
    sock.destroy();
    if (handled) return;
    handled = true;
    delegate('dashboard.js', rest);
  });
  sock.connect(dashResolved.port, '127.0.0.1');
} else if (engineCmds.has(cmd)) {
  delegate('engine.js', [cmd, ...rest]);
} else if (require('./cli-api-client').isHandled(cmd)) {
  // Generic `minions api` passthrough + ergonomic per-resource subcommands
  // (wi/plans/schedule/watch/feature/settings). All route through the
  // dashboard's HTTP API at localhost:7331 so the auth/CSRF/origin contract
  // is identical to dashboard UI calls — no parallel state-mutation paths.
  ensureInstalled();
  require('./cli-api-client').dispatch(cmd, rest).then(code => process.exit(code || 0))
    .catch(err => { console.error(err && err.message || err); process.exit(2); });
} else {
  console.log(`  Unknown command: ${cmd}`);
  console.log('  Run "minions help" for usage.\n');
  process.exit(1);
}
