/**
 * bin/cli-api-client.js — Minions CLI ↔ dashboard HTTP API bridge.
 *
 * Two layers:
 *   1. Generic: `minions api <METHOD> <PATH> [--body json|--body-file f]`
 *      — direct passthrough to http://localhost:7331/api/* so every endpoint
 *      that the dashboard exposes is also reachable from the CLI without
 *      hand-writing a subcommand for each one. Surfaces gaps and lets power
 *      users script anything the dashboard can do.
 *   2. Ergonomic per-resource subcommands (wi/plans/watch/schedule/feature/
 *      settings) — thin wrappers over the generic call with human-friendly
 *      output. Both layers share one HTTP code path so the auth/CSRF/origin
 *      contract is identical.
 *
 * The dashboard's Origin gate allows requests with no Origin/Referer header
 * (curl + CLI tooling) — see dashboard.js comment "When both headers are
 * absent ... we allow the request to preserve existing local automation."
 * The CLI deliberately sends no Origin so it goes through that path.
 */

const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');

/**
 * Resolve the dashboard base URL on EVERY call so commands invoked while the
 * dashboard auto-fallback bound to a non-default port (W-mq5nwl9l) still
 * reach it. Precedence:
 *   1. MINIONS_DASHBOARD_URL env var (full URL — wins absolutely)
 *   2. engine/dashboard-port.json runtime file (the live dashboard's actual port)
 *   3. http://localhost:7331 (legacy default)
 *
 * Inlined here (rather than going through engine/shared.js) so the CLI client
 * stays runtime-loadable even if shared.js fails to load mid-upgrade.
 */
function _readRuntimePort() {
  try {
    const home = process.env.MINIONS_HOME || path.join(os.homedir(), '.minions');
    const raw = fs.readFileSync(path.join(home, 'engine', 'dashboard-port.json'), 'utf8');
    const data = JSON.parse(raw);
    const port = Number(data && data.port);
    if (Number.isInteger(port) && port > 0 && port <= 65535) return port;
  } catch {}
  return null;
}

function getDefaultBaseUrl() {
  if (process.env.MINIONS_DASHBOARD_URL) return process.env.MINIONS_DASHBOARD_URL;
  const port = _readRuntimePort();
  if (port) return `http://localhost:${port}`;
  return 'http://localhost:7331';
}

// Backwards-compat: an evaluated-at-import snapshot for callers that just want
// "the value at the time the module loaded". Live consumers should call
// getDefaultBaseUrl() (default-parameter evaluation inside apiCall does that
// for them transparently).
const DEFAULT_BASE_URL = getDefaultBaseUrl();

// ── HTTP core ───────────────────────────────────────────────────────────────

/**
 * Make an HTTP request to the dashboard. Returns `{ status, body, raw }` where
 * `body` is the parsed JSON (or string if not JSON) and `raw` is the original
 * response text. Does not throw on non-2xx — callers decide.
 *
 * Errors surfaced via thrown Error are connection-level: ECONNREFUSED when
 * the dashboard isn't running, ETIMEDOUT, etc. Distinct from API-level errors
 * which come back in the response body.
 */
/**
 * Normalize a URL path that may have been mangled by a shell.
 *
 * On Windows Git Bash / MSYS, a leading `/api/...` argument is auto-translated
 * to `C:/Program Files/Git/api/...` BEFORE node sees argv. That makes
 * `minions api GET /api/status` blow up with "Invalid URL". This function:
 *   - strips known Git-Bash prefixes ('C:/Program Files/Git', '/c/Program Files/Git', ...)
 *   - re-extracts the path starting at the first `/api/` occurrence
 *   - accepts paths without a leading slash (`api/status` → `/api/status`)
 *
 * Pure / no side effects so unit-testable.
 */
function _normalizeApiPath(input) {
  if (!input) return input;
  let s = String(input);
  // Already an absolute http(s) URL — leave alone.
  if (/^https?:\/\//i.test(s)) return s;
  // Git Bash / MSYS translation: extract from the FIRST `/api/` we find.
  const apiIdx = s.indexOf('/api/');
  if (apiIdx > 0) return s.slice(apiIdx);
  // Missing leading slash: `minions api GET api/status` → `/api/status`.
  if (s.startsWith('api/')) return '/' + s;
  return s;
}

function apiCall(method, urlPath, bodyJson, { baseUrl, timeoutMs = 30000 } = {}) {
  if (!baseUrl) baseUrl = getDefaultBaseUrl();
  return new Promise((resolve, reject) => {
    const normalized = _normalizeApiPath(urlPath);
    let u;
    try { u = new URL(normalized.startsWith('http') ? normalized : baseUrl + normalized); }
    catch (e) { return reject(new Error(`Invalid URL: ${urlPath} (normalized: ${normalized})`)); }

    const bodyStr = bodyJson != null ? (typeof bodyJson === 'string' ? bodyJson : JSON.stringify(bodyJson)) : null;
    const headers = { 'Accept': 'application/json' };
    if (bodyStr) {
      headers['Content-Type'] = 'application/json';
      headers['Content-Length'] = Buffer.byteLength(bodyStr);
    }

    const req = http.request({
      method: method.toUpperCase(),
      hostname: u.hostname,
      port: u.port || 80,
      path: u.pathname + u.search,
      headers,
      timeout: timeoutMs,
    }, (res) => {
      let buf = '';
      res.setEncoding('utf8');
      res.on('data', (chunk) => { buf += chunk; });
      res.on('end', () => {
        let parsed = buf;
        if (res.headers['content-type'] && res.headers['content-type'].includes('json')) {
          try { parsed = JSON.parse(buf); } catch { /* keep raw */ }
        }
        resolve({ status: res.statusCode || 0, body: parsed, raw: buf });
      });
    });

    req.on('error', (err) => {
      if (err.code === 'ECONNREFUSED') {
        reject(new Error(`Dashboard not reachable at ${baseUrl}. Is minions running? Try \`minions restart\`.`));
      } else {
        reject(err);
      }
    });
    req.on('timeout', () => { req.destroy(); reject(new Error(`Request timed out after ${timeoutMs}ms`)); });
    if (bodyStr) req.write(bodyStr);
    req.end();
  });
}

// ── Argv parsing helpers ────────────────────────────────────────────────────

/**
 * Pull a flag value out of an argv array. Returns the value (string) or null.
 * Supports both `--flag value` and `--flag=value`. Removes the consumed
 * tokens so the caller can pass the remainder as positional args.
 */
function takeFlag(argv, flag) {
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === flag) {
      const v = argv[i + 1];
      argv.splice(i, 2);
      return v;
    }
    if (a.startsWith(flag + '=')) {
      const v = a.slice(flag.length + 1);
      argv.splice(i, 1);
      return v;
    }
  }
  return null;
}

function hasFlag(argv, flag) {
  const i = argv.indexOf(flag);
  if (i === -1) return false;
  argv.splice(i, 1);
  return true;
}

// ── Output formatting ───────────────────────────────────────────────────────

function jsonOut(obj) {
  process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
}

function lineOut(s) { process.stdout.write(String(s) + '\n'); }

// One-line summary of a work item — used by `minions wi list`.
function _formatWiOneLine(wi) {
  const id = wi.id || '';
  const status = (wi.status || '').padEnd(11);
  const agent = (wi.agent || '-').padEnd(10);
  const title = (wi.title || '').slice(0, 70);
  return `  ${id.padEnd(28)} ${status} ${agent} ${title}`;
}

function _formatPlanOneLine(p) {
  const slug = (p.slug || p.file || p.id || '').padEnd(36);
  const status = (p.status || '').padEnd(14);
  const title = (p.title || '').slice(0, 60);
  return `  ${slug} ${status} ${title}`;
}

// ── Generic passthrough: `minions api <METHOD> <PATH> [...]` ────────────────

async function cliApi(argv) {
  const baseUrlDefault = getDefaultBaseUrl();
  const help = `Usage: minions api <METHOD> <PATH> [--body JSON | --body-file FILE] [--base-url URL] [--quiet]

Direct HTTP passthrough to the dashboard API at ${baseUrlDefault}.

Examples:
  minions api GET  /api/status
  minions api GET  /api/work-items
  minions api POST /api/work-items/cancel --body '{"id":"W-abc"}'
  minions api POST /api/work-items --body-file new-item.json

Flags:
  --body <json>      Inline JSON request body
  --body-file <f>    Read JSON body from a file
  --base-url <url>   Override default dashboard URL (env MINIONS_DASHBOARD_URL also works)
  --quiet            Suppress non-zero status logging; just print the body

Exit codes: 0 = 2xx, 1 = 4xx/5xx, 2 = connection failure / bad usage`;

  if (argv.length === 0 || hasFlag(argv, '--help') || hasFlag(argv, '-h')) {
    lineOut(help);
    return 2;
  }
  const baseUrl = takeFlag(argv, '--base-url') || baseUrlDefault;
  const bodyInline = takeFlag(argv, '--body');
  const bodyFile = takeFlag(argv, '--body-file');
  const quiet = hasFlag(argv, '--quiet');
  if (argv.length < 2) {
    lineOut('Error: METHOD and PATH required\n');
    lineOut(help);
    return 2;
  }
  const [method, urlPath] = argv;

  let body = null;
  if (bodyInline) {
    try { body = JSON.parse(bodyInline); }
    catch (e) { lineOut(`Error: --body is not valid JSON: ${e.message}`); return 2; }
  } else if (bodyFile) {
    try { body = JSON.parse(fs.readFileSync(bodyFile, 'utf8')); }
    catch (e) { lineOut(`Error: cannot read/parse --body-file: ${e.message}`); return 2; }
  }

  try {
    const res = await apiCall(method, urlPath, body, { baseUrl });
    if (typeof res.body === 'string') process.stdout.write(res.body + (res.body.endsWith('\n') ? '' : '\n'));
    else jsonOut(res.body);
    if (!quiet && (res.status < 200 || res.status >= 300)) {
      process.stderr.write(`HTTP ${res.status}\n`);
    }
    return (res.status >= 200 && res.status < 300) ? 0 : 1;
  } catch (e) {
    process.stderr.write(`${e.message}\n`);
    return 2;
  }
}

// ── work-items (wi) ─────────────────────────────────────────────────────────

async function cliWi(argv) {
  const sub = argv.shift();
  const help = `Usage: minions wi <subcommand> [args]

Subcommands:
  list [--status S] [--agent A] [--project P] [--json]   List work items
  show <id> [--json]                                     Show one work item
  cancel <id> [--reason R]                               Cancel a pending/active item
  retry <id>                                             Reset a failed item to pending
  delete <id>                                            Remove a work item (and kill agent)
  archive <id>                                           Move completed/failed item to archive
  reopen <id> [--description D]                          Reopen a done/failed item to pending
  feedback <id> --rating up|down [--comment C]           Add human feedback`;

  if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { lineOut(help); return 2; }

  if (sub === 'list') {
    const status = takeFlag(argv, '--status');
    const agent = takeFlag(argv, '--agent');
    const project = takeFlag(argv, '--project');
    const json = hasFlag(argv, '--json');
    const res = await apiCall('GET', '/api/work-items');
    if (res.status !== 200) { jsonOut(res.body); return 1; }
    let items = Array.isArray(res.body) ? res.body : (res.body.items || []);
    if (status) items = items.filter(i => i.status === status);
    if (agent) items = items.filter(i => i.agent === agent);
    if (project) items = items.filter(i => i.project === project || i._project === project);
    if (json) { jsonOut(items); return 0; }
    lineOut(`${items.length} work item(s)`);
    for (const i of items) lineOut(_formatWiOneLine(i));
    return 0;
  }

  if (sub === 'show') {
    const id = argv.shift();
    if (!id) { lineOut('Error: work item id required'); return 2; }
    const json = hasFlag(argv, '--json');
    const res = await apiCall('GET', `/api/work-items/${encodeURIComponent(id)}`);
    if (res.status === 404) { lineOut(`Not found: ${id}`); return 1; }
    if (res.status !== 200) { jsonOut(res.body); return 1; }
    if (json) { jsonOut(res.body); return 0; }
    const wi = res.body;
    lineOut(`${wi.id}  [${wi.status}]  agent=${wi.agent || '-'}  type=${wi.type || '-'}`);
    if (wi.title) lineOut(`  title: ${wi.title}`);
    if (wi.description) lineOut(`  description: ${String(wi.description).slice(0, 300)}`);
    if (wi.created_at) lineOut(`  created: ${wi.created_at}`);
    return 0;
  }

  if (['cancel', 'retry', 'delete', 'archive', 'reopen'].includes(sub)) {
    const id = argv.shift();
    if (!id) { lineOut(`Error: work item id required`); return 2; }
    const body = { id };
    if (sub === 'cancel') {
      const reason = takeFlag(argv, '--reason');
      if (reason) body.reason = reason;
    }
    if (sub === 'reopen') {
      const description = takeFlag(argv, '--description');
      if (description) body.description = description;
    }
    const res = await apiCall('POST', `/api/work-items/${sub}`, body);
    if (res.status === 200 || res.status === 202) {
      lineOut(`${sub}: ${id}`);
      return 0;
    }
    process.stderr.write(`HTTP ${res.status}\n`);
    jsonOut(res.body);
    return 1;
  }

  if (sub === 'feedback') {
    const id = argv.shift();
    if (!id) { lineOut('Error: work item id required'); return 2; }
    const rating = takeFlag(argv, '--rating');
    if (!rating || !['up', 'down'].includes(rating)) {
      lineOut('Error: --rating up|down required'); return 2;
    }
    const comment = takeFlag(argv, '--comment');
    const body = { id, rating };
    if (comment) body.comment = comment;
    const res = await apiCall('POST', '/api/work-items/feedback', body);
    if (res.status === 200) { lineOut(`feedback recorded for ${id}`); return 0; }
    process.stderr.write(`HTTP ${res.status}\n`);
    jsonOut(res.body);
    return 1;
  }

  lineOut(`Unknown wi subcommand: ${sub}\n`);
  lineOut(help);
  return 2;
}

// ── plans ───────────────────────────────────────────────────────────────────

async function cliPlans(argv) {
  const sub = argv.shift();
  const help = `Usage: minions plans <subcommand> [args]

Subcommands:
  list [--status S] [--json]            List plans (and their PRDs)
  show <slug-or-file> [--json]          Show a plan/PRD's content
  approve <slug-or-file>                Approve a plan for execution
  pause <slug-or-file>                  Pause materialization + reset active items
  reject <slug-or-file>                 Reject a plan
  archive <slug-or-file>                Move plan/PRD to archive
  unarchive <slug-or-file>              Restore from archive
  regenerate <slug-or-file>             Reset pending/failed items so they re-materialize`;

  if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { lineOut(help); return 2; }

  if (sub === 'list') {
    const status = takeFlag(argv, '--status');
    const json = hasFlag(argv, '--json');
    const res = await apiCall('GET', '/api/plans');
    if (res.status !== 200) { jsonOut(res.body); return 1; }
    let items = Array.isArray(res.body) ? res.body : (res.body.plans || []);
    if (status) items = items.filter(p => p.status === status);
    if (json) { jsonOut(items); return 0; }
    lineOut(`${items.length} plan(s)`);
    for (const p of items) lineOut(_formatPlanOneLine(p));
    return 0;
  }

  if (sub === 'show') {
    const id = argv.shift();
    if (!id) { lineOut('Error: plan slug or file required'); return 2; }
    const json = hasFlag(argv, '--json');
    const res = await apiCall('GET', `/api/plans/${encodeURIComponent(id)}`);
    if (res.status !== 200) { jsonOut(res.body); return 1; }
    if (json) { jsonOut(res.body); return 0; }
    if (typeof res.body === 'string') lineOut(res.body);
    else jsonOut(res.body);
    return 0;
  }

  if (['approve', 'pause', 'reject', 'archive', 'unarchive', 'regenerate'].includes(sub)) {
    const id = argv.shift();
    if (!id) { lineOut('Error: plan slug or file required'); return 2; }
    // The plans API expects either `slug` or `file` in the body. We try `slug`
    // first; servers that want `file` accept either based on lookup.
    const body = { slug: id, file: id };
    const res = await apiCall('POST', `/api/plans/${sub}`, body);
    if (res.status === 200 || res.status === 202) {
      lineOut(`${sub}: ${id}`);
      return 0;
    }
    process.stderr.write(`HTTP ${res.status}\n`);
    jsonOut(res.body);
    return 1;
  }

  lineOut(`Unknown plans subcommand: ${sub}\n`);
  lineOut(help);
  return 2;
}

// ── schedules ───────────────────────────────────────────────────────────────

async function cliSchedule(argv) {
  const sub = argv.shift();
  const help = `Usage: minions schedule <subcommand> [args]

Subcommands:
  list [--json]                         List all schedules with last-run state
  show <id> [--json]                    Show one schedule
  run-now <id>                          Manually enqueue the work item for a schedule
  delete <id>                           Delete a schedule
  add --body <json> | --body-file <f>   Create a schedule from JSON spec
  update --body <json> | --body-file f  Update a schedule
  parse <natural-language-text>         Convert "every weekday at 9am" → cron

Note: \`add\` and \`update\` take a full schedule spec — see /api/routes for the
shape, or run \`minions api GET /api/schedules\` and adapt an existing entry.`;

  if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { lineOut(help); return 2; }

  if (sub === 'list') {
    const json = hasFlag(argv, '--json');
    const res = await apiCall('GET', '/api/schedules');
    if (res.status !== 200) { jsonOut(res.body); return 1; }
    let items = Array.isArray(res.body) ? res.body : (res.body.schedules || []);
    if (json) { jsonOut(items); return 0; }
    lineOut(`${items.length} schedule(s)`);
    for (const s of items) {
      const lastRun = s._lastRun || s.lastRunAt || '(never)';
      const enabled = s.enabled === false ? '[disabled]' : '          ';
      lineOut(`  ${(s.id || '').padEnd(28)} ${enabled} cron="${s.cron || ''}"  last=${lastRun}`);
    }
    return 0;
  }

  if (sub === 'show') {
    const id = argv.shift();
    if (!id) { lineOut('Error: schedule id required'); return 2; }
    const res = await apiCall('GET', '/api/schedules');
    if (res.status !== 200) { jsonOut(res.body); return 1; }
    const items = Array.isArray(res.body) ? res.body : (res.body.schedules || []);
    const found = items.find(s => s.id === id);
    if (!found) { lineOut(`Not found: ${id}`); return 1; }
    jsonOut(found);
    return 0;
  }

  if (sub === 'run-now' || sub === 'delete') {
    const id = argv.shift();
    if (!id) { lineOut('Error: schedule id required'); return 2; }
    const res = await apiCall('POST', `/api/schedules/${sub}`, { id });
    if (res.status === 200) { lineOut(`${sub}: ${id}`); return 0; }
    process.stderr.write(`HTTP ${res.status}\n`);
    jsonOut(res.body);
    return 1;
  }

  if (sub === 'add' || sub === 'update') {
    const bodyInline = takeFlag(argv, '--body');
    const bodyFile = takeFlag(argv, '--body-file');
    let body = null;
    if (bodyInline) {
      try { body = JSON.parse(bodyInline); }
      catch (e) { lineOut(`Error: --body invalid JSON: ${e.message}`); return 2; }
    } else if (bodyFile) {
      try { body = JSON.parse(fs.readFileSync(bodyFile, 'utf8')); }
      catch (e) { lineOut(`Error: cannot read --body-file: ${e.message}`); return 2; }
    } else { lineOut('Error: --body or --body-file required'); return 2; }
    const apiPath = sub === 'add' ? '/api/schedules' : '/api/schedules/update';
    const res = await apiCall('POST', apiPath, body);
    if (res.status === 200 || res.status === 201) { jsonOut(res.body); return 0; }
    process.stderr.write(`HTTP ${res.status}\n`);
    jsonOut(res.body);
    return 1;
  }

  if (sub === 'parse') {
    const text = argv.join(' ').trim();
    if (!text) { lineOut('Error: natural language text required'); return 2; }
    const res = await apiCall('POST', '/api/schedules/parse-natural', { text });
    jsonOut(res.body);
    return res.status === 200 ? 0 : 1;
  }

  lineOut(`Unknown schedule subcommand: ${sub}\n`);
  lineOut(help);
  return 2;
}

// ── watches ─────────────────────────────────────────────────────────────────

async function cliWatch(argv) {
  const sub = argv.shift();
  const help = `Usage: minions watch <subcommand> [args]

Subcommands:
  list [--json]                         List all watches with current status
  show <id> [--json]                    Show one watch with full history
  delete <id>                           Delete a watch
  add --body <json> | --body-file <f>   Create a watch from JSON spec
  update --body <json> | --body-file f  Update a watch (pause/resume/modify)
  target-types                          List registered target types + conditions
  action-types                          List registered follow-up action types

Note: \`add\` and \`update\` take a full watch spec — see /api/routes for the
shape, or \`minions api GET /api/watches\` for examples.`;

  if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { lineOut(help); return 2; }

  if (sub === 'list') {
    const json = hasFlag(argv, '--json');
    const res = await apiCall('GET', '/api/watches');
    if (res.status !== 200) { jsonOut(res.body); return 1; }
    const items = Array.isArray(res.body) ? res.body : (res.body.watches || []);
    if (json) { jsonOut(items); return 0; }
    lineOut(`${items.length} watch(es)`);
    for (const w of items) {
      const status = (w.status || 'unknown').padEnd(8);
      const target = `${w.targetType || '-'}:${w.target || '-'}`.padEnd(30);
      lineOut(`  ${(w.id || '').padEnd(28)} ${status} ${target} ${w.condition || ''}`);
    }
    return 0;
  }

  if (sub === 'show') {
    const id = argv.shift();
    if (!id) { lineOut('Error: watch id required'); return 2; }
    const [main, history] = await Promise.all([
      apiCall('GET', '/api/watches'),
      apiCall('GET', `/api/watches/${encodeURIComponent(id)}/history`),
    ]);
    const items = Array.isArray(main.body) ? main.body : (main.body.watches || []);
    const w = items.find(x => x.id === id);
    if (!w) { lineOut(`Not found: ${id}`); return 1; }
    jsonOut({ watch: w, history: history.body });
    return 0;
  }

  if (sub === 'target-types' || sub === 'action-types') {
    const res = await apiCall('GET', `/api/watches/${sub}`);
    jsonOut(res.body);
    return res.status === 200 ? 0 : 1;
  }

  if (sub === 'delete') {
    const id = argv.shift();
    if (!id) { lineOut('Error: watch id required'); return 2; }
    const res = await apiCall('POST', '/api/watches/delete', { id });
    if (res.status === 200) { lineOut(`delete: ${id}`); return 0; }
    process.stderr.write(`HTTP ${res.status}\n`);
    jsonOut(res.body);
    return 1;
  }

  if (sub === 'add' || sub === 'update') {
    const bodyInline = takeFlag(argv, '--body');
    const bodyFile = takeFlag(argv, '--body-file');
    let body = null;
    if (bodyInline) {
      try { body = JSON.parse(bodyInline); }
      catch (e) { lineOut(`Error: --body invalid JSON: ${e.message}`); return 2; }
    } else if (bodyFile) {
      try { body = JSON.parse(fs.readFileSync(bodyFile, 'utf8')); }
      catch (e) { lineOut(`Error: cannot read --body-file: ${e.message}`); return 2; }
    } else { lineOut('Error: --body or --body-file required'); return 2; }
    const apiPath = sub === 'add' ? '/api/watches' : '/api/watches/update';
    const res = await apiCall('POST', apiPath, body);
    if (res.status === 200 || res.status === 201) { jsonOut(res.body); return 0; }
    process.stderr.write(`HTTP ${res.status}\n`);
    jsonOut(res.body);
    return 1;
  }

  lineOut(`Unknown watch subcommand: ${sub}\n`);
  lineOut(help);
  return 2;
}

// ── feature flags ──────────────────────────────────────────────────────────

async function cliFeature(argv) {
  const sub = argv.shift();
  const help = `Usage: minions feature <subcommand> [args]

Subcommands:
  list [--json]                         List registered feature flags
  toggle <id> [--on | --off]            Enable/disable a feature flag (omit for explicit toggle)`;

  if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { lineOut(help); return 2; }

  if (sub === 'list') {
    const json = hasFlag(argv, '--json');
    const res = await apiCall('GET', '/api/features');
    if (res.status !== 200) { jsonOut(res.body); return 1; }
    const items = Array.isArray(res.body) ? res.body : (res.body.features || []);
    if (json) { jsonOut(items); return 0; }
    lineOut(`${items.length} feature flag(s)`);
    for (const f of items) {
      const on = f.enabled === true ? 'ON ' : (f.enabled === false ? 'off' : '   ');
      lineOut(`  [${on}] ${(f.id || '').padEnd(28)} ${(f.description || '').slice(0, 70)}`);
    }
    return 0;
  }

  if (sub === 'toggle') {
    const id = argv.shift();
    if (!id) { lineOut('Error: feature id required'); return 2; }
    const on = hasFlag(argv, '--on');
    const off = hasFlag(argv, '--off');
    const body = { id };
    if (on) body.enabled = true;
    else if (off) body.enabled = false;
    const res = await apiCall('POST', '/api/features/toggle', body);
    if (res.status === 200) { jsonOut(res.body); return 0; }
    process.stderr.write(`HTTP ${res.status}\n`);
    jsonOut(res.body);
    return 1;
  }

  lineOut(`Unknown feature subcommand: ${sub}\n`);
  lineOut(help);
  return 2;
}

// ── settings ───────────────────────────────────────────────────────────────

async function cliSettings(argv) {
  const sub = argv.shift();
  const help = `Usage: minions settings <subcommand> [args]

Subcommands:
  get [<dotted.path>] [--json]          Get full config or a single dotted path
  set <dotted.path> <value>             Set a config value (auto-parsed: numbers/booleans/JSON)
  reset                                 Reset engine + claude + agent settings to defaults

Examples:
  minions settings get engine.maxConcurrent
  minions settings set engine.maxConcurrent 8
  minions settings set engine.evalLoop true
  minions settings set engine.ccTurnTimeoutMs 600000`;

  if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { lineOut(help); return 2; }

  if (sub === 'get') {
    const dottedPath = argv.shift();
    const json = hasFlag(argv, '--json');
    const res = await apiCall('GET', '/api/settings');
    if (res.status !== 200) { jsonOut(res.body); return 1; }
    let value = res.body;
    if (dottedPath) {
      for (const part of dottedPath.split('.')) {
        if (value == null) break;
        value = value[part];
      }
      if (value === undefined) { lineOut(`(not set)`); return 1; }
    }
    if (json || typeof value === 'object') { jsonOut(value); }
    else { lineOut(String(value)); }
    return 0;
  }

  if (sub === 'set') {
    const dottedPath = argv.shift();
    const rawValue = argv.shift();
    if (!dottedPath || rawValue === undefined) {
      lineOut('Error: dotted.path and value required'); return 2;
    }
    // Coerce value: try JSON parse first (catches numbers, booleans, JSON objects), fall back to string.
    let value;
    try { value = JSON.parse(rawValue); }
    catch { value = rawValue; }
    // Build a nested patch object from the dotted path. /api/settings accepts
    // a partial config that gets merged onto the existing one.
    const patch = {};
    const parts = dottedPath.split('.');
    let cursor = patch;
    for (let i = 0; i < parts.length - 1; i++) { cursor[parts[i]] = {}; cursor = cursor[parts[i]]; }
    cursor[parts[parts.length - 1]] = value;
    const res = await apiCall('POST', '/api/settings', patch);
    if (res.status === 200) { lineOut(`set ${dottedPath} = ${JSON.stringify(value)}`); return 0; }
    process.stderr.write(`HTTP ${res.status}\n`);
    jsonOut(res.body);
    return 1;
  }

  if (sub === 'reset') {
    const res = await apiCall('POST', '/api/settings/reset', {});
    if (res.status === 200) { lineOut('settings reset to defaults'); return 0; }
    process.stderr.write(`HTTP ${res.status}\n`);
    jsonOut(res.body);
    return 1;
  }

  lineOut(`Unknown settings subcommand: ${sub}\n`);
  lineOut(help);
  return 2;
}

// ── Top-level dispatcher ────────────────────────────────────────────────────
//
// `bin/minions.js` calls `dispatch(cmd, argv)`; we return either an exit code
// promise (handled command) or null (not ours — let minions.js continue).

const HANDLERS = {
  'api':       cliApi,
  'wi':        cliWi,
  'plans':     cliPlans,
  'schedule':  cliSchedule,
  'schedules': cliSchedule,  // accept both singular and plural
  'watch':     cliWatch,
  'watches':   cliWatch,
  'feature':   cliFeature,
  'features':  cliFeature,
  'settings':  cliSettings,
};

function isHandled(cmd) { return Object.prototype.hasOwnProperty.call(HANDLERS, cmd); }

async function dispatch(cmd, argv) {
  const fn = HANDLERS[cmd];
  if (!fn) return null;
  return await fn(argv);
}

module.exports = {
  apiCall,
  takeFlag,
  hasFlag,
  isHandled,
  dispatch,
  DEFAULT_BASE_URL,
  getDefaultBaseUrl,
  // Per-command handlers exposed for unit tests that want to drive them
  // directly without going through process.argv.
  _handlers: { cliApi, cliWi, cliPlans, cliSchedule, cliWatch, cliFeature, cliSettings },
  _normalizeApiPath,
};
