#!/bin/bash
set -e

# Drafted — Shared surface for AI-human collaboration
# This script installs the Drafted MCP server and CLI globally via npm,
# then registers it with Claude Desktop, Claude Code, Codex, and Cursor.
#
# Run with:
#   curl -fsSL https://drafted.live/install.sh | bash

SERVER="https://drafted.live"
INSTALLER_VERSION="1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_MODE="production"
INSTALL_NAME="${DRAFTED_MCP_NAME:-drafted}"
INSTALL_SERVER="${DRAFTED_SERVER:-$SERVER}"
INSTALL_AUTH_FILE="${DRAFTED_AUTH_FILE:-}"

while [ $# -gt 0 ]; do
  case "$1" in
    --local)
      INSTALL_MODE="local"
      INSTALL_NAME="${DRAFTED_MCP_NAME:-drafted-local}"
      INSTALL_SERVER="${DRAFTED_SERVER:-http://localhost:3477}"
      INSTALL_AUTH_FILE="${DRAFTED_AUTH_FILE:-$HOME/.drafted/auth.local.json}"
      if [ -z "${DRAFTED_TELEMETRY+x}" ]; then export DRAFTED_TELEMETRY=0; fi
      shift
      ;;
    --server)
      INSTALL_SERVER="$2"
      shift 2
      ;;
    --name)
      INSTALL_NAME="$2"
      shift 2
      ;;
    --auth-file)
      INSTALL_AUTH_FILE="$2"
      shift 2
      ;;
    --help|-h)
      echo "Usage: install-mcp.sh [--local] [--server URL] [--name MCP_NAME] [--auth-file PATH]"
      echo "  default: installs production MCP named drafted -> https://drafted.live"
      echo "  --local: installs duplicate MCP named drafted-local -> http://localhost:3477"
      exit 0
      ;;
    *)
      echo "Unknown option: $1" >&2
      exit 1
      ;;
  esac
done


BOLD="\033[1m"
DIM="\033[2m"
GREEN="\033[32m"
YELLOW="\033[33m"
RED="\033[31m"
CYAN="\033[36m"
RESET="\033[0m"

step=0
step() {
  step=$((step + 1))
  echo ""
  echo -e "${CYAN}[$step]${RESET} ${BOLD}$1${RESET}"
}

ok() {
  echo -e "    ${GREEN}✓${RESET} $1"
}

fail() {
  echo -e "    ${RED}✗${RESET} $1"
}

init_telemetry() {
  INSTALL_ID=""
  TELEMETRY_ENABLED=true
  if [ "${DRAFTED_TELEMETRY:-}" = "0" ]; then
    TELEMETRY_ENABLED=false
    return 0
  fi
  mkdir -p "$HOME/.drafted"
  INSTALL_ID="$(node - "$HOME/.drafted/install.json" <<'NODE'
const fs = require('fs');
const crypto = require('crypto');
const p = process.argv[2];
let data = {};
try { data = JSON.parse(fs.readFileSync(p, 'utf8')); } catch {}
if (data.telemetry === false) process.exit(2);
if (!/^[0-9a-f-]{36}$/i.test(String(data.installId || ''))) data.installId = crypto.randomUUID();
data.telemetry = data.telemetry !== false;
data.updatedAt = new Date().toISOString();
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
process.stdout.write(data.installId);
NODE
)" || TELEMETRY_ENABLED=false
  if [ "$TELEMETRY_ENABLED" = true ]; then
    echo -e "    ${DIM}Drafted sends anonymous install telemetry. Set DRAFTED_TELEMETRY=0 to opt out.${RESET}"
  fi
}

report_telemetry() {
  [ "${TELEMETRY_ENABLED:-false}" = true ] || return 0
  [ -n "${INSTALL_ID:-}" ] || return 0
  local event="$1"
  local helper_status="${2:-installed}"
  node - "$SERVER" "$INSTALL_ID" "$event" "$INSTALLER_VERSION" "$helper_status" \
    "${CLIENT_CLAUDE_DESKTOP:-false}" "${CLIENT_CLAUDE_CODE:-false}" "${CLIENT_CODEX:-false}" "${CLIENT_CURSOR:-false}" <<'NODE' >/dev/null 2>&1 || true
const [server, installId, event, installerVersion, updateHelperStatus, claudeDesktop, claudeCode, codex, cursor] = process.argv.slice(2);
const os = require('os');
const cp = require('child_process');
function run(cmd) { try { return cp.execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().slice(0, 80); } catch { return undefined; } }
const platform = os.platform();
const osFamily = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : platform === 'linux' ? 'linux' : 'unknown';
fetch(`${server}/api/installations/report`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'User-Agent': 'Drafted Installer' },
  body: JSON.stringify({
    installId,
    event,
    schemaVersion: 1,
    installerVersion,
    cliVersion: run('drafted --version'),
    osFamily,
    osVersion: os.release(),
    arch: os.arch(),
    nodeVersion: process.version,
    npmVersion: run('npm --version'),
    clientsConfigured: {
      claudeDesktop: claudeDesktop === 'true',
      claudeCode: claudeCode === 'true',
      codex: codex === 'true',
      cursor: cursor === 'true'
    },
    updateHelperStatus,
    mcpMode: 'stdio',
    source: 'installer'
  })
}).catch(() => {});
NODE
}

install_portable_node() {
  local os arch platform filename url tmp node_dir
  os="$(uname -s)"
  arch="$(uname -m)"
  case "$os" in
    Darwin) platform="darwin" ;;
    Linux) platform="linux" ;;
    *) fail "Unsupported OS for automatic Node.js install: $os"; exit 1 ;;
  esac
  case "$arch" in
    x86_64|amd64) arch="x64" ;;
    arm64|aarch64) arch="arm64" ;;
    *) fail "Unsupported CPU for automatic Node.js install: $arch"; exit 1 ;;
  esac

  mkdir -p "$HOME/.drafted"
  node_dir="$HOME/.drafted/node"
  tmp="$(mktemp -d)"
  filename="$(curl -fsSL https://nodejs.org/dist/latest-v22.x/SHASUMS256.txt | awk "/node-v.*-${platform}-${arch}\\.tar\\.xz/ {print \\$2; exit}")"
  if [ -z "$filename" ]; then
    fail "Could not find Node.js 22 download for $platform-$arch"
    exit 1
  fi
  url="https://nodejs.org/dist/latest-v22.x/$filename"
  echo -e "    ${YELLOW}Downloading Node.js 22 for $platform-$arch...${RESET}"
  curl -fsSL "$url" -o "$tmp/node.tar.xz"
  rm -rf "$node_dir"
  mkdir -p "$node_dir"
  tar -xJf "$tmp/node.tar.xz" -C "$node_dir" --strip-components=1
  rm -rf "$tmp"
  export PATH="$node_dir/bin:$PATH"
  ok "Installed portable Node.js $(node -v)"
}

# ── Welcome ───────────────────────────────────────────────────────

echo ""
echo -e "${BOLD}Welcome to Drafted${RESET}"
echo -e "This will set up Drafted so your agents can create work on a shared surface."
echo -e "It only takes a minute."
if [ "$INSTALL_MODE" = "local" ]; then
  echo -e "${DIM}Local mode: installing MCP ${BOLD}$INSTALL_NAME${RESET}${DIM} -> $INSTALL_SERVER without touching production drafted.${RESET}"
fi

# ── Prerequisites ─────────────────────────────────────────────────

step "Checking your system"

# Node.js
if command -v node &>/dev/null; then
  NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
  if [ "$NODE_VERSION" -lt 22 ]; then
    echo -e "    ${YELLOW}Node.js $(node -v) is too old; installing Node.js 22 for Drafted.${RESET}"
    install_portable_node
  else
    ok "Node.js $(node -v)"
  fi
else
  echo -e "    ${YELLOW}Node.js is not installed; installing Node.js 22 for Drafted.${RESET}"
  install_portable_node
fi

init_telemetry

NPM_GLOBAL_PREFIX="$HOME/.drafted/npm-global"
mkdir -p "$NPM_GLOBAL_PREFIX"

# A `drafted` resolved elsewhere on PATH (a leftover install from before this
# fixed-prefix scheme, or a manual `npm i -g` under the default prefix) would
# permanently shadow the fixed-prefix copy: the updater keeps this prefix
# current, but the shell keeps running the stale one. Remove it at its source.
STALE_DRAFTED_BIN="$(command -v drafted 2>/dev/null || true)"
if [ -n "$STALE_DRAFTED_BIN" ]; then
  # Derived from where the resolved binary actually sits (bin's grandparent),
  # not `npm config get prefix` — that reflects our own target prefix once
  # it's already been set on a prior run, which would make this a no-op on
  # every machine that's already installed once (i.e. every real machine).
  CANDIDATE_PREFIX="$(dirname "$(dirname "$STALE_DRAFTED_BIN")")"
  if [ "$CANDIDATE_PREFIX" != "$NPM_GLOBAL_PREFIX" ] && [ -d "$CANDIDATE_PREFIX/lib/node_modules/drafted" ]; then
    echo -e "    ${YELLOW}Found a stale Drafted install at $CANDIDATE_PREFIX — removing it so it can't shadow the current one.${RESET}"
    npm uninstall -g drafted --prefix "$CANDIDATE_PREFIX" >/dev/null 2>&1 || true
  fi
fi

# Do NOT `npm config set prefix` — that repoints the user's GLOBAL npm prefix, so every
# `npm install -g <pkg>` they ever run lands in our dir AND our uninstall (`rm -rf ~/.drafted`)
# would wipe all their other globals. Install drafted with an explicit per-command --prefix
# instead (below), and HEAL any global prefix pin an older version of this installer wrote so
# the user's default is restored. Preserve every other ~/.npmrc line (auth tokens, etc.).
NPMRC="${npm_config_userconfig:-$HOME/.npmrc}"
if [ -f "$NPMRC" ] && grep -Eq '^[[:space:]]*prefix[[:space:]]*=.*/\.drafted/npm-global/?[[:space:]]*$' "$NPMRC"; then
  tmp_npmrc="$(mktemp)"
  grep -Ev '^[[:space:]]*prefix[[:space:]]*=.*/\.drafted/npm-global/?[[:space:]]*$' "$NPMRC" > "$tmp_npmrc" && cat "$tmp_npmrc" > "$NPMRC"
  rm -f "$tmp_npmrc"
fi
export PATH="$NPM_GLOBAL_PREFIX/bin:$PATH"

# Persist the prefix's bin dir at the FRONT of PATH for future shells — without
# this, npm's prefix config makes `npm install -g` land in the right place, but
# a bare `drafted` in a new terminal keeps resolving through whatever was
# already on PATH (see the installedVersion() comment on the login-shell PATH
# gap below). Idempotent guarded block, same pattern as the agent-instructions
# markers further down.
persist_npm_global_path() {
  local marker_begin="# BEGIN drafted-path"
  local marker_end="# END drafted-path"
  local line="export PATH=\"$NPM_GLOBAL_PREFIX/bin:\$PATH\""
  local rc
  case "$(basename "${SHELL:-}")" in
    zsh) rc="$HOME/.zshrc" ;;
    bash) rc="$HOME/.bash_profile" ;;
    *) rc="$HOME/.profile" ;;
  esac
  [ -f "$rc" ] || : > "$rc"
  grep -qF "$marker_begin" "$rc" 2>/dev/null && return 0
  { echo ""; echo "$marker_begin"; echo "$line"; echo "$marker_end"; } >> "$rc"
}
persist_npm_global_path

# ── Install ───────────────────────────────────────────────────────

step "Installing Drafted"

# Retry with back-off: a fresh `npm publish` can briefly 404/ETARGET on the CDN
# edge before the tarball propagates, so an updater that fires the instant a new
# version lands would otherwise report a spurious "update failed". Ride it out.
install_drafted_pkg() {
  attempts=5; delay=4; n=1
  while :; do
    if npm install -g drafted@latest --force --prefix "$NPM_GLOBAL_PREFIX"; then return 0; fi
    if [ "$n" -ge "$attempts" ]; then return 1; fi
    echo -e "    ${YELLOW}npm install failed (attempt $n/$attempts) — retrying in ${delay}s (a new release may still be propagating to the npm CDN)...${RESET}"
    sleep "$delay"
    n=$((n + 1)); delay=$((delay * 2))
  done
}
if ! install_drafted_pkg; then
  echo -e "    ${RED}npm install -g drafted@latest failed after multiple attempts.${RESET}"
  exit 1
fi
hash -r 2>/dev/null || true
NPM_ROOT="$(npm root -g --prefix "$NPM_GLOBAL_PREFIX" 2>/dev/null || true)"
MCP_SERVER_MODULE="$NPM_ROOT/drafted/mcp/server.mjs"
if [ -n "$NPM_ROOT" ] && [ -f "$MCP_SERVER_MODULE" ]; then
  node -e "import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(process.argv[1]).href)).then(() => process.exit(0), (err) => { console.error(err); process.exit(1); })" "$MCP_SERVER_MODULE"
elif [ "${DRAFTED_HEADLESS:-}" = "1" ]; then
  echo -e "    ${DIM}Skipping package import check in headless installer test.${RESET}"
else
  echo -e "    ${RED}Could not find installed MCP server module at $MCP_SERVER_MODULE${RESET}"
  exit 1
fi
ok "Installed $(drafted --version 2>/dev/null || echo 'drafted') via npm"

# ── Configure ─────────────────────────────────────────────────────

step "Connecting to your tools"

# Write server URL config
mkdir -p "$HOME/.drafted"
if [ "$INSTALL_MODE" = "production" ]; then
  echo "{\"server\":\"$INSTALL_SERVER\"}" > "$HOME/.drafted/config.json"
  ok "Server: $INSTALL_SERVER"
else
  ok "Local server: $INSTALL_SERVER"
fi

# Local stdio MCP command. Production uses the installed package; --local uses this checkout when available.
if [ "$INSTALL_MODE" = "local" ] && [ -f "$SCRIPT_DIR/mcp/server.mjs" ]; then
  DRAFTED_MCP_COMMAND="$(command -v node)"
  DRAFTED_MCP_ARGS_JSON="$(node -e 'console.log(JSON.stringify([process.argv[1]]))' "$SCRIPT_DIR/mcp/server.mjs")"
else
  DRAFTED_MCP_COMMAND="$(command -v drafted-mcp)"
  DRAFTED_MCP_ARGS_JSON="[]"
fi

configure_mcp() {
  local config_path="$1"
  local label="$2"
  local config_dir
  config_dir="$(dirname "$config_path")"
  mkdir -p "$config_dir"

  node -e "
    const fs = require('fs');
    const p = process.argv[1];
    const name = process.argv[2];
    const command = process.argv[3];
    const args = JSON.parse(process.argv[4]);
    const server = process.argv[5];
    const authFile = process.argv[6];
    const mode = process.argv[7];
    let c = {};
    try { c = JSON.parse(fs.readFileSync(p, 'utf8')); } catch {}
    if (!c.mcpServers) c.mcpServers = {};
    const env = { DRAFTED_SERVER: server };
    if (authFile) env.DRAFTED_AUTH_FILE = authFile;
    if (mode === 'local') env.DRAFTED_TELEMETRY = '0';
    c.mcpServers[name] = { command, args, env };
    fs.writeFileSync(p, JSON.stringify(c, null, 2) + '\n');
  " "$config_path" "$INSTALL_NAME" "$DRAFTED_MCP_COMMAND" "$DRAFTED_MCP_ARGS_JSON" "$INSTALL_SERVER" "$INSTALL_AUTH_FILE" "$INSTALL_MODE"
  ok "$label"
}


verify_no_legacy_http_config() {
  local found=false
  for file in "$CLAUDE_DESKTOP_CONFIG" "$CLAUDE_CODE_CONFIG" "$CURSOR_CONFIG" "$CODEX_CONFIG"; do
    [ -f "$file" ] || continue
    if grep -q "https://drafted.live/mcp" "$file" 2>/dev/null; then
      found=true
      echo -e "    ${RED}✗${RESET} Legacy HTTP Drafted MCP still present in $file"
    fi
  done
  if [ "$found" = true ]; then
    fail "Installer migration incomplete. Remove legacy https://drafted.live/mcp entries and rerun."
    exit 1
  fi
}

configure_codex() {
  local config_path="$1"
  local label="$2"
  local config_dir
  config_dir="$(dirname "$config_path")"
  mkdir -p "$config_dir"

  node - "$config_path" "$INSTALL_NAME" "$DRAFTED_MCP_COMMAND" "$DRAFTED_MCP_ARGS_JSON" "$INSTALL_SERVER" "$INSTALL_AUTH_FILE" "$INSTALL_MODE" <<'NODE'
const fs = require('fs');
const p = process.argv[2];
const mcpName = process.argv[3];
const command = process.argv[4];
const args = JSON.parse(process.argv[5]);
const server = process.argv[6];
const authFile = process.argv[7];
const mode = process.argv[8];

let text = '';
try { text = fs.readFileSync(p, 'utf8'); } catch {}

const lines = text.split(/\r?\n/);
const out = [];
let skip = false;

for (const line of lines) {
  const section = line.match(/^\[(.+)\]$/);
  if (section) {
    const name = section[1].trim();
    if (name === `mcp_servers.${mcpName}` || name === `mcp_servers.${mcpName}.env`) {
      skip = true;
      continue;
    }
    if (skip) skip = false;
  }
  if (!skip) out.push(line);
}

let next = out.join('\n').replace(/\s+$/, '');
if (next) next += '\n\n';
next += `[mcp_servers.${mcpName}]\n`;
next += 'command = ' + JSON.stringify(command) + '\n';
next += 'args = [' + args.map(a => JSON.stringify(a)).join(', ') + ']\n';
next += `\n[mcp_servers.${mcpName}.env]\n`;
next += 'DRAFTED_SERVER = ' + JSON.stringify(server) + '\n';
if (authFile) next += 'DRAFTED_AUTH_FILE = ' + JSON.stringify(authFile) + '\n';
if (mode === 'local') next += 'DRAFTED_TELEMETRY = "0"\n';
next += '\n';

fs.writeFileSync(p, next);
NODE

  ok "$label"
}

CLAUDE_DESKTOP_CONFIG="$HOME/Library/Application Support/Claude/claude_desktop_config.json"
CLAUDE_CODE_CONFIG="$HOME/.claude.json"
CODEX_CONFIG="$HOME/.codex/config.toml"
CURSOR_CONFIG="$HOME/.cursor/mcp.json"

CONFIGURED=false
CLIENT_CLAUDE_DESKTOP=false
CLIENT_CLAUDE_CODE=false
CLIENT_CODEX=false
CLIENT_CURSOR=false

# Claude Desktop
if [ -d "/Applications/Claude.app" ] || [ -d "$HOME/Applications/Claude.app" ] || [ -f "$CLAUDE_DESKTOP_CONFIG" ]; then
  configure_mcp "$CLAUDE_DESKTOP_CONFIG" "Claude Desktop"
  CLIENT_CLAUDE_DESKTOP=true
  CONFIGURED=true
fi

# Claude Code
if command -v claude &>/dev/null || [ -f "$CLAUDE_CODE_CONFIG" ] || [ -d "$HOME/.claude" ]; then
  configure_mcp "$CLAUDE_CODE_CONFIG" "Claude Code"
  CLIENT_CLAUDE_CODE=true
  CONFIGURED=true
fi

# Codex
if command -v codex &>/dev/null || [ -d "$HOME/.codex" ]; then
  configure_codex "$CODEX_CONFIG" "Codex"
  CLIENT_CODEX=true
  CONFIGURED=true
fi

# Cursor
if [ -d "/Applications/Cursor.app" ] || [ -d "$HOME/Applications/Cursor.app" ] || command -v cursor &>/dev/null || [ -f "$CURSOR_CONFIG" ]; then
  configure_mcp "$CURSOR_CONFIG" "Cursor"
  CLIENT_CURSOR=true
  CONFIGURED=true
fi

# If nothing detected, pre-configure all
if [ "$CONFIGURED" = false ]; then
  echo -e "    ${YELLOW}No supported editors detected — pre-configuring all.${RESET}"
  configure_mcp "$CLAUDE_DESKTOP_CONFIG" "Claude Desktop (pre-configured)"
  configure_mcp "$CLAUDE_CODE_CONFIG" "Claude Code (pre-configured)"
  configure_codex "$CODEX_CONFIG" "Codex (pre-configured)"
  configure_mcp "$CURSOR_CONFIG" "Cursor (pre-configured)"
  CLIENT_CLAUDE_DESKTOP=true
  CLIENT_CLAUDE_CODE=true
  CLIENT_CODEX=true
  CLIENT_CURSOR=true
fi

# ── Agent instructions ────────────────────────────────────────────

step "Installing global agent instructions"

load_global_agent_instructions() {
  local local_path="$SCRIPT_DIR/agent-instructions/global.md"
  if [ -f "$local_path" ]; then
    cat "$local_path"
    return 0
  fi
  cat <<'DRAFTED_GLOBAL_INSTRUCTIONS'
<drafted>
Drafted is the shared workspace for agent-produced consulting artifacts. Claude Code and Codex agents should use it proactively when the task benefits from persistent visual work, client-facing documents, reusable skills, or organization knowledge.

Drafted terminology:
- Wiki: durable organization knowledge. It may also be called the knowledge base or database. Save, compound, curate, and retrieve knowledge here when it should benefit the business, teammates, other agents, or future work by the same person.
- Skills: reusable operating knowledge. Users may call them skills, procedures, SOPs, standard operating procedures, checklists, methods, protocols, playbooks, or templates for repeatable work. Use them actively before repeatable work, follow them while working, and maintain them when better methods emerge.
- Projects: client-facing or reviewable work products, organized as frames on a shared surface. Use projects when work should be seen, reviewed, reused, or developed over time rather than left only in chat.

Operating principle:
- Do not treat Drafted as passive storage. Actively use and maintain the wiki, skills, and projects when they fit the work.
- Default to creating or updating Drafted project frames for substantive outputs instead of leaving artifacts only in chat. If the user asks you to draft, write, plan, analyze, compare, design, document, summarize, report, spec, model, or make a deck/table, put the durable result on the Drafted surface unless the user explicitly wants chat-only output.
- Put durable knowledge in the wiki, reusable methods in skills, and producible artifacts in projects.
- Do not save reusable procedures, SOPs, checklists, methods, protocols, or repeatable work instructions to the wiki/knowledge base. Save them as Drafted skills.

Before working:
- Check whether Drafted MCP tools are available. If not authenticated, use auth(action="get_link") or auth(action="login") and give the user the sign-in link.
- Select the correct organization first. Use get_org(action="get") and project(action="list") to identify the target org/project. Opening a project switches org context automatically; for wiki/skill-only work, use get_org(action="switch", orgId=...).
- Check get_org(action="get") for googleDrive.connected. When Google Drive is connected for the active org, Google Workspace frames are available and should be the strong default for documents, spreadsheets, and presentations.
- Never assume the current active Drafted project or organization is correct. Verify the returned project/org before writing.

Wiki rules:
- Search the org wiki before substantive work: wiki(action="search") with relevant keywords.
- Read relevant pages before creating or changing artifacts.
- Maintain the wiki when you discover durable knowledge, decisions, client constraints, project context, research notes, or facts that should compound for the business or future agents.
- When creating or editing wiki pages, return a browser link the user can click.

Skill rules:
- Treat skill, procedure, SOP, checklist, method, protocol, playbook, and repeatable-work template as the same user intent.
- Search skills before starting repeatable work: skill(action="search") or skill(action="list").
- Load and follow relevant skills with skill(action="load"). Read supporting skill files when needed.
- When the user asks to record, distill, create, save, install, or update a skill/procedure/SOP/checklist/method/protocol/playbook, create or update a Drafted skill for the org with skill tools.
- Every skill has a portable part — its method, SKILL.md, and any script source — and that part always belongs in Drafted, not only in a local repo. When you build a runnable skill, push the source to Drafted (skill action="push", or skill action="add" for prose) and declare how to rebuild it in the skill's `setup:` frontmatter (e.g. ["npm ci"]) so any machine or agent can regenerate it. Do not leave a reusable skill authored only as local files when Drafted is connected.
- Machine-specific build output (node_modules, downloaded browsers, compiled binaries) is never portable: build it into a `.skillinstall/` directory inside the skill. Drafted always strips `.skillinstall/` from a pushed bundle and skill push auto-gitignores it, so the rebuildable bundle stays local while the method and recipe stay in Drafted.
- Improve skills when you find a better checklist, standard, or operating method.

Project/producible rules:
- For client-facing, durable, or reviewable artifacts, produce the work inside the appropriate Drafted project instead of leaving it only in chat.
- Prefer one visible frame per artifact or artifact section so the user can review, compare, and refine work on the surface.
- Use project(action="open") before frame/asset/connector/layout changes. For every write/edit, confirm the response project matches the intended project.
- Read anchored frames and existing neighboring frames before editing a layer.
- After creating frames, documents, diagrams, or other producibles, use focus when available and return the clickable Drafted frame or project link.

Google Workspace rules:
- If get_org reports googleDrive.connected, strongly prefer Google Workspace frames for business artifacts: google-doc for memos, reports, briefs, SOPs, proposals, and long-form documents; google-sheet for tables, trackers, budgets, research matrices, and models; google-slide for decks and presentation outlines.
- Create Google Workspace frames with frame(action="write", googleType="google-doc" | "google-sheet" | "google-slide", path="/{layer}/{lane}/{filename}", title="...") after opening the project.
- For flowcharts, process maps, architecture diagrams, system diagrams, data-flow diagrams, visual maps, or other editable diagrams, load and follow the system skill `excalidraw-drafted` (Drafted Excalidraw Diagram) and create native Drafted Excalidraw frames. Use normal Drafted HTML/markdown frames for web/UI mockups, rich visual layouts, or non-editable presentation artifacts.
- Use breadcrumbs in code or docs where useful: drafted:<frameId> for frames and drafted-project:<projectId> for projects.

Collaboration rules:
- Parallel agents must each open and verify their own Drafted project/org context.
- If there is any doubt about the correct org, project, wiki location, or skill, ask a short clarifying question before writing.
</drafted>
DRAFTED_GLOBAL_INSTRUCTIONS
}

install_agent_instructions() {
  local instructions_path="$1"
  local label="$2"
  local instructions_dir
  local body
  instructions_dir="$(dirname "$instructions_path")"
  mkdir -p "$instructions_dir"

  if ! body="$(load_global_agent_instructions)"; then
    fail "Could not load Drafted global instructions"
    return 1
  fi

  DRAFTED_GLOBAL_INSTRUCTIONS_BODY="$body" node - "$instructions_path" <<'NODE'
const fs = require('fs');
const p = process.argv[2];
const begin = '<!-- BEGIN drafted-global-instructions -->';
const end = '<!-- END drafted-global-instructions -->';
const body = process.env.DRAFTED_GLOBAL_INSTRUCTIONS_BODY || '';
if (!body.trim()) throw new Error('Drafted global instructions are empty');
const block = `${begin}\n${body.replace(/\s+$/, '')}\n${end}`;
let text = '';
try { text = fs.readFileSync(p, 'utf8'); } catch {}
const escapeRe = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`${escapeRe(begin)}[\\s\\S]*?${escapeRe(end)}`);
let next;
if (re.test(text)) {
  next = text.replace(re, block);
} else {
  next = text.replace(/\s+$/, '');
  if (next) next += '\n\n';
  next += block + '\n';
}
fs.writeFileSync(p, next.endsWith('\n') ? next : next + '\n');
NODE

  ok "$label"
}

install_agent_instructions "$HOME/.claude/CLAUDE.md" "Claude global instructions"
install_agent_instructions "$HOME/.codex/CODEX.md" "Codex global instructions"

# ── Skills & commands ─────────────────────────────────────────────

step "Installing skills and commands"

# Resolve the plugin source: a local checkout when present (e.g. --local from this
# repo), otherwise the installed npm package's bundled plugin/ directory.
# require.resolve() misses packages under the custom global prefix
# ($HOME/.drafted/npm-global) when run from an arbitrary cwd (curl | bash), so
# prefer the already-computed global node_modules root from the install step.
if [ -n "${NPM_ROOT:-}" ] && [ -d "$NPM_ROOT/drafted" ]; then
  DRAFTED_PKG_DIR="$NPM_ROOT/drafted"
else
  DRAFTED_PKG_DIR="$(node -e "try { console.log(require.resolve('drafted/package.json').replace('/package.json','')) } catch { process.exit(1) }" 2>/dev/null)" || true
fi
if [ -d "$SCRIPT_DIR/plugin/skills" ] || [ -d "$SCRIPT_DIR/plugin/commands" ]; then
  PLUGIN_SRC="$SCRIPT_DIR/plugin"
elif [ -n "$DRAFTED_PKG_DIR" ] && [ -d "$DRAFTED_PKG_DIR/plugin" ]; then
  PLUGIN_SRC="$DRAFTED_PKG_DIR/plugin"
else
  PLUGIN_SRC=""
fi

# Skills — Claude Code auto-loads these from ~/.claude/skills/<name>/SKILL.md
if [ -n "$PLUGIN_SRC" ] && [ -d "$PLUGIN_SRC/skills" ]; then
  SKILLS_DIR="$HOME/.claude/skills"
  mkdir -p "$SKILLS_DIR"
  SKILL_COUNT=0
  for d in "$PLUGIN_SRC/skills/"*/; do
    [ -d "$d" ] || continue
    DIRNAME="$(basename "$d")"
    rm -rf "$SKILLS_DIR/$DIRNAME"
    cp -r "$d" "$SKILLS_DIR/$DIRNAME"
    SKILL_COUNT=$((SKILL_COUNT + 1))
  done
  ok "Installed $SKILL_COUNT skill(s) to $SKILLS_DIR"
else
  echo -e "    ${YELLOW}Skill source not found in package — skipping skills.${RESET}"
fi

# Commands — namespaced under drafted/ so they register as /drafted:<name>,
# matching the marketplace install and the commands' own cross-references.
if [ -n "$PLUGIN_SRC" ] && [ -d "$PLUGIN_SRC/commands" ]; then
  CMD_COUNT=0
  for f in "$PLUGIN_SRC/commands/"*.md; do
    [ -f "$f" ] || continue
    CMD_COUNT=$((CMD_COUNT + 1))
  done

  if [ "$CLIENT_CLAUDE_CODE" = true ]; then
    CLAUDE_CMD_DIR="$HOME/.claude/commands/drafted"
    rm -rf "$CLAUDE_CMD_DIR"
    mkdir -p "$CLAUDE_CMD_DIR"
    cp "$PLUGIN_SRC/commands/"*.md "$CLAUDE_CMD_DIR/" 2>/dev/null || true
    ok "Installed $CMD_COUNT command(s) to $CLAUDE_CMD_DIR"
  fi

  if [ "$CLIENT_CODEX" = true ]; then
    CODEX_CMD_DIR="$HOME/.codex/prompts/drafted"
    rm -rf "$CODEX_CMD_DIR"
    mkdir -p "$CODEX_CMD_DIR"
    cp "$PLUGIN_SRC/commands/"*.md "$CODEX_CMD_DIR/" 2>/dev/null || true
    ok "Installed $CMD_COUNT command(s) to $CODEX_CMD_DIR"
  fi
else
  echo -e "    ${YELLOW}Command source not found in package — skipping commands.${RESET}"
fi


# ── Update menu bar helper ───────────────────────────────────────

# Idempotently remove the legacy Swift menu-bar updater so it can neither run alongside the
# new desktop app nor be resurrected at next login. Safe to call when nothing legacy exists.
retire_legacy_updater() {
  local uid legacy_plist
  uid="$(id -u)"
  legacy_plist="$HOME/Library/LaunchAgents/live.drafted.updater.plist"
  launchctl bootout "gui/$uid/live.drafted.updater" >/dev/null 2>&1 || true
  if [ -f "$legacy_plist" ]; then
    launchctl bootout "gui/$uid" "$legacy_plist" >/dev/null 2>&1 || true
  fi
  rm -f "$legacy_plist" >/dev/null 2>&1 || true
  pkill -x DraftedUpdater >/dev/null 2>&1 || true
  rm -rf "/Applications/Drafted Updater.app" "$HOME/Applications/Drafted Updater.app" >/dev/null 2>&1 || true
}

# Install (or detect) the Tauri desktop app that replaces the legacy menu-bar updater.
# Returns 0 ONLY when a valid Drafted.app is present afterward, so the caller can safely
# retire the legacy updater. Returns 1 when no new bundle is available yet — the caller then
# falls back to building the legacy Swift updater, so users are never left without a tray.
install_desktop_app() {
  local install_dir app_bundle app_exe uid plist tmp tarball url
  install_dir="/Applications"
  [ -w "$install_dir" ] || install_dir="$HOME/Applications"
  mkdir -p "$install_dir"
  app_bundle="$install_dir/Drafted.app"

  if [ -n "${DRAFTED_DESKTOP_APP:-}" ] && [ -d "${DRAFTED_DESKTOP_APP}" ]; then
    # Local prebuilt bundle — lets us test the cutover before the CDN endpoint exists.
    rm -rf "$app_bundle" >/dev/null 2>&1 || true
    ditto "${DRAFTED_DESKTOP_APP}" "$app_bundle" >/dev/null 2>&1 || true
  else
    # Signed bundle tarball (expects Drafted.app at the archive root). Until this endpoint is
    # published the request 404s and we fall through to legacy — that is the cutover gate.
    url="${DRAFTED_DESKTOP_URL:-$INSTALL_SERVER/desktop/Drafted-macos.tar.gz}"
    tmp="$(mktemp -d)"
    tarball="$tmp/Drafted-macos.tar.gz"
    if curl -fsSL --max-time 120 "$url" -o "$tarball" >/dev/null 2>&1; then
      rm -rf "$app_bundle" >/dev/null 2>&1 || true
      tar -xzf "$tarball" -C "$install_dir" >/dev/null 2>&1 || true
    fi
    rm -rf "$tmp" >/dev/null 2>&1 || true
  fi

  # Validate: a real bundle whose declared executable exists. The bundle dir is Drafted.app
  # (productName) but the binary is the Cargo bin name (drafted-desktop), so read the actual
  # name from Info.plist rather than assuming. If absent/invalid, signal "not available yet".
  local exe_name
  exe_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app_bundle/Contents/Info.plist" 2>/dev/null || true)"
  app_exe="$app_bundle/Contents/MacOS/$exe_name"
  if [ -z "$exe_name" ] || [ ! -x "$app_exe" ]; then
    return 1
  fi

  # Clear quarantine so Gatekeeper doesn't block a freshly downloaded bundle.
  xattr -dr com.apple.quarantine "$app_bundle" >/dev/null 2>&1 || true

  # Kill any already-running instance first — including a stray one that isn't
  # launchd-managed (e.g. left over from a prior run of this same installer,
  # before this pkill existed: `open` below used to spawn a second, untracked
  # process alongside the launchd-supervised one, and `launchctl bootout` only
  # ever stops the launchd-managed process, not that rogue one).
  pkill -x "$exe_name" >/dev/null 2>&1 || true

  # Register run-at-login for the new app (mirrors the legacy launch agent).
  uid="$(id -u)"
  plist="$HOME/Library/LaunchAgents/live.drafted.desktop.plist"
  mkdir -p "$HOME/Library/LaunchAgents"
  cat > "$plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>live.drafted.desktop</string>
  <key>ProgramArguments</key>
  <array>
    <string>$app_exe</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>
</dict>
</plist>
PLIST
  # launchctl bootstrap + RunAtLoad is the sole launch path — it already starts
  # the app, so a trailing `open "$app_bundle"` here would start a second,
  # launchd-unaware process every time this function runs (that was the bug).
  launchctl bootout "gui/$uid" "$plist" >/dev/null 2>&1 || true
  launchctl bootstrap "gui/$uid" "$plist" >/dev/null 2>&1 || true
  return 0
}

install_update_menu_icon() {
  if [ "${DRAFTED_HEADLESS:-}" = "1" ]; then
    echo -e "    ${YELLOW}Headless mode: skipping update menu icon.${RESET}"
    return 0
  fi
  if [ "$(uname -s)" != "Darwin" ]; then
    echo -e "    ${YELLOW}Update menu icon is only installed on macOS by this script.${RESET}"
    return 0
  fi

  # Prefer the Tauri desktop app. If it installs (or is already present and valid), retire the
  # legacy Swift updater so the two can never run side by side or be resurrected at next login.
  if install_desktop_app; then
    retire_legacy_updater
    ok "Drafted desktop app"
    return 0
  fi
  echo -e "    ${YELLOW}Desktop app bundle not available yet; using the menu bar updater.${RESET}"

  if ! command -v swiftc >/dev/null 2>&1; then
    echo -e "    ${YELLOW}Swift compiler not found; skipping macOS menu bar updater.${RESET}"
    return 0
  fi

  local work_dir install_dir app_bundle app_macos app_resources swift_file exe_path plist_dir plist uid logo_path legacy_bundle
  work_dir="$HOME/.drafted/updater"
  install_dir="/Applications"
  if [ ! -w "$install_dir" ]; then
    install_dir="$HOME/Applications"
  fi
  app_bundle="$install_dir/Drafted Updater.app"
  app_macos="$app_bundle/Contents/MacOS"
  app_resources="$app_bundle/Contents/Resources"
  swift_file="$work_dir/DraftedUpdater.swift"
  exe_path="$app_macos/DraftedUpdater"
  plist_dir="$HOME/Library/LaunchAgents"
  plist="$plist_dir/live.drafted.updater.plist"
  uid="$(id -u)"
  legacy_bundle="$work_dir/DraftedUpdater.app"
  mkdir -p "$work_dir" "$install_dir" "$app_macos" "$app_resources" "$plist_dir"
  rm -rf "$legacy_bundle"

  logo_path="$app_resources/logo.svg"
  if [ -f "$SCRIPT_DIR/server/vendor/logo.svg" ]; then
    cp "$SCRIPT_DIR/server/vendor/logo.svg" "$logo_path"
  else
    curl -fsSL "$INSTALL_SERVER/vendor/logo.svg" -o "$logo_path" >/dev/null 2>&1 || true
  fi
  if [ -f "$logo_path" ]; then
    perl -0pi -e 's/fill="currentColor"/fill="#FFFFFF"/g; s/fill="var\(--logo-letter, #[^)]+\)"/fill="#0A2540"/g' "$logo_path" >/dev/null 2>&1 || true
  fi

  cat > "$swift_file" <<'SWIFT'
import SwiftUI
import AppKit

@main
struct DraftedUpdaterApp: App {
  @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

  init() {
    Telemetry.report(event: "drafted_update_helper_started", updateHelperStatus: "running")
    Telemetry.report(event: "drafted_heartbeat", updateHelperStatus: "running")
  }

  var body: some Scene {
    MenuBarExtra {
      Button("Check for Updates…") { Actions.checkForUpdate(userInitiated: true) }
      Button("Update Drafted") { Actions.updateDrafted() }
      Button("View Logs") { Actions.viewLogs() }
      Button("Open Drafted") { Actions.openDrafted() }
      Divider()
      Button("Quit") { NSApp.terminate(nil) }
    } label: {
      if let image = Actions.logoImage() {
        Image(nsImage: image)
          .resizable()
          .frame(width: 16, height: 18)
          .accessibilityLabel("Drafted")
      } else {
        Text("Drafted")
      }
    }
    .menuBarExtraStyle(.menu)
  }
}

// Drives the genuine auto-update: a background check shortly after login and
// every 6 hours while the menu-bar helper is resident. When a newer published
// version exists (and the user hasn't skipped it), it prompts Update / Skip.
final class AppDelegate: NSObject, NSApplicationDelegate {
  private var timer: Timer?

  func applicationDidFinishLaunching(_ notification: Notification) {
    DispatchQueue.main.asyncAfter(deadline: .now() + 25) {
      Actions.checkForUpdate(userInitiated: false)
    }
    let t = Timer(timeInterval: 6 * 3600, repeats: true) { _ in
      Actions.checkForUpdate(userInitiated: false)
    }
    RunLoop.main.add(t, forMode: .common)
    timer = t
  }
}


final class UpdateStatusWindow: NSWindowController {
  private let stack = NSStackView()
  private let spinner = NSProgressIndicator()
  private let titleField = NSTextField(labelWithString: "Updating Drafted…")
  private let bodyField = NSTextField(labelWithString: "Downloading and running the installer. This can take a minute.")
  private let closeButton = NSButton(title: "OK", target: nil, action: nil)

  init() {
    let panel = NSPanel(
      contentRect: NSRect(x: 0, y: 0, width: 380, height: 168),
      styleMask: [.titled, .closable],
      backing: .buffered,
      defer: false
    )
    panel.title = "Drafted Update"
    panel.isReleasedWhenClosed = false
    panel.level = .floating
    super.init(window: panel)

    stack.orientation = .vertical
    stack.alignment = .centerX
    stack.spacing = 12
    stack.translatesAutoresizingMaskIntoConstraints = false

    spinner.style = .spinning
    spinner.controlSize = .regular

    titleField.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
    titleField.alignment = .center

    bodyField.font = NSFont.systemFont(ofSize: 13)
    bodyField.textColor = .secondaryLabelColor
    bodyField.alignment = .center
    bodyField.maximumNumberOfLines = 3
    bodyField.lineBreakMode = .byWordWrapping

    closeButton.target = self
    closeButton.action = #selector(closeClicked)
    closeButton.isHidden = true

    stack.addArrangedSubview(spinner)
    stack.addArrangedSubview(titleField)
    stack.addArrangedSubview(bodyField)
    stack.addArrangedSubview(closeButton)

    panel.contentView = NSView()
    panel.contentView?.addSubview(stack)
    NSLayoutConstraint.activate([
      stack.leadingAnchor.constraint(equalTo: panel.contentView!.leadingAnchor, constant: 28),
      stack.trailingAnchor.constraint(equalTo: panel.contentView!.trailingAnchor, constant: -28),
      stack.centerYAnchor.constraint(equalTo: panel.contentView!.centerYAnchor)
    ])
  }

  required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }

  func showUpdating() {
    titleField.stringValue = "Updating Drafted…"
    bodyField.stringValue = "Downloading and running the installer. This can take a minute."
    closeButton.isHidden = true
    spinner.isHidden = false
    spinner.startAnimation(nil)
    NSApp.activate(ignoringOtherApps: true)
    showWindow(nil)
    window?.center()
  }

  func showCompleted(success: Bool, message: String) {
    spinner.stopAnimation(nil)
    spinner.isHidden = true
    titleField.stringValue = success ? "Update completed" : "Update failed"
    bodyField.stringValue = message
    closeButton.isHidden = false
    NSApp.activate(ignoringOtherApps: true)
    showWindow(nil)
    window?.center()
  }

  @objc private func closeClicked() { window?.close() }
}

struct Actions {
  static let home = FileManager.default.homeDirectoryForCurrentUser

  static func logoImage() -> NSImage? {
    guard
      let url = Bundle.main.url(forResource: "logo", withExtension: "svg"),
      let image = NSImage(contentsOf: url)
    else { return nil }
    image.size = NSSize(width: 16, height: 18)
    image.isTemplate = false
    return image
  }

  private static var isUpdating = false
  private static var updateWindow: UpdateStatusWindow?

  // MARK: - Auto-update check

  // Compare the installed CLI version to npm's published `latest`. Prompt only
  // when newer and not skipped (background), or always report (user-initiated).
  static func checkForUpdate(userInitiated: Bool) {
    let installed = installedVersion()
    latestVersion { latest in
      DispatchQueue.main.async {
        guard let latest = latest else {
          if userInitiated {
            showInfo(title: "Couldn’t check for updates", body: "Please check your connection and try again.")
          }
          return
        }
        guard let installed = installed, isNewer(latest, than: installed) else {
          if userInitiated {
            showInfo(title: "Drafted is up to date", body: "You’re on \(installed ?? "the latest") version.")
          }
          return
        }
        if !userInitiated && skippedVersion() == latest { return }
        presentUpdateAlert(latest: latest, installed: installed)
      }
    }
  }

  static func presentUpdateAlert(latest: String, installed: String) {
    Telemetry.report(event: "drafted_update_available", updateHelperStatus: "running")
    let alert = NSAlert()
    alert.messageText = "Drafted \(latest) is available"
    alert.informativeText = "You have \(installed). Update now? Your editor needs to be restarted afterward to load the new MCP tools."
    alert.addButton(withTitle: "Update")
    alert.addButton(withTitle: "Skip This Version")
    alert.addButton(withTitle: "Later")
    if let logo = logoImage() { alert.icon = logo }
    NSApp.activate(ignoringOtherApps: true)
    switch alert.runModal() {
    case .alertFirstButtonReturn:
      updateDrafted()
    case .alertSecondButtonReturn:
      setSkippedVersion(latest)
      Telemetry.report(event: "drafted_update_skipped", updateHelperStatus: "running")
    default:
      break
    }
  }

  static func showInfo(title: String, body: String) {
    let alert = NSAlert()
    alert.messageText = title
    alert.informativeText = body
    alert.addButton(withTitle: "OK")
    if let logo = logoImage() { alert.icon = logo }
    NSApp.activate(ignoringOtherApps: true)
    alert.runModal()
  }

  // Installed version, resolved WITHOUT depending on the login-shell PATH —
  // the installer's npm prefix (~/.drafted/npm-global) is never written to any
  // login profile, so `bash -lc "drafted"` finds nothing. Read the installed
  // package.json at the fixed prefix first (the source npm itself writes), then
  // fall back to the absolute binary, then a login shell (custom prefixes on PATH).
  static func installedVersion() -> String? {
    let pkg = home.appendingPathComponent(".drafted/npm-global/lib/node_modules/drafted/package.json")
    if
      let data = try? Data(contentsOf: pkg),
      let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
      let version = json["version"] as? String,
      isSemver(version)
    { return version }

    let bin = home.appendingPathComponent(".drafted/npm-global/bin/drafted").path
    for command in ["\"\(bin)\" --version 2>/dev/null", "drafted --version 2>/dev/null"] {
      let process = Process()
      process.executableURL = URL(fileURLWithPath: "/bin/bash")
      process.arguments = ["-lc", command]
      let pipe = Pipe()
      process.standardOutput = pipe
      process.standardError = FileHandle.nullDevice
      do { try process.run(); process.waitUntilExit() } catch { continue }
      let data = pipe.fileHandleForReading.readDataToEndOfFile()
      if let out = String(data: data, encoding: .utf8), let version = firstSemver(in: out) {
        return version
      }
    }
    return nil
  }

  static func latestVersion(_ completion: @escaping (String?) -> Void) {
    guard let url = URL(string: "https://registry.npmjs.org/drafted/latest") else { completion(nil); return }
    var request = URLRequest(url: url)
    request.timeoutInterval = 15
    URLSession.shared.dataTask(with: request) { data, _, _ in
      guard
        let data = data,
        let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
        let version = json["version"] as? String
      else { completion(nil); return }
      completion(version)
    }.resume()
  }

  static func skippedVersion() -> String? {
    let path = home.appendingPathComponent(".drafted/install.json")
    guard
      let data = try? Data(contentsOf: path),
      let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
    else { return nil }
    return json["skippedVersion"] as? String
  }

  static func setSkippedVersion(_ version: String) {
    let path = home.appendingPathComponent(".drafted/install.json")
    var json = ((try? Data(contentsOf: path)).flatMap { try? JSONSerialization.jsonObject(with: $0) } as? [String: Any]) ?? [:]
    json["skippedVersion"] = version
    if let out = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) {
      try? out.write(to: path)
    }
  }

  // First dotted-numeric token in the text, e.g. "1.11.7" from "drafted 1.11.7".
  static func firstSemver(in text: String) -> String? {
    var current = ""
    for ch in text {
      if ch.isNumber || ch == "." {
        current.append(ch)
      } else {
        if isSemver(current) { return current }
        current = ""
      }
    }
    return isSemver(current) ? current : nil
  }

  static func isSemver(_ s: String) -> Bool {
    let parts = s.split(separator: ".")
    return parts.count >= 2 && parts.allSatisfy { !$0.isEmpty && $0.allSatisfy { $0.isNumber } }
  }

  // Numeric, dot-separated comparison (our versions have no prerelease tags).
  static func isNewer(_ a: String, than b: String) -> Bool {
    let pa = a.split(separator: ".").map { Int($0) ?? 0 }
    let pb = b.split(separator: ".").map { Int($0) ?? 0 }
    for i in 0..<max(pa.count, pb.count) {
      let x = i < pa.count ? pa[i] : 0
      let y = i < pb.count ? pb[i] : 0
      if x != y { return x > y }
    }
    return false
  }

  static func updateDrafted() {
    if isUpdating {
      updateWindow?.showUpdating()
      return
    }

    isUpdating = true
    let statusWindow = UpdateStatusWindow()
    updateWindow = statusWindow
    statusWindow.showUpdating()

    let command = "tmp=$(mktemp); curl -fsSL https://drafted.live/install.sh -o \"$tmp\" && bash \"$tmp\""
    let process = Process()
    process.executableURL = URL(fileURLWithPath: "/bin/bash")
    process.arguments = ["-lc", command]
    process.terminationHandler = { proc in
      if proc.terminationStatus != 0 {
        Telemetry.report(event: "drafted_update_helper_failed", updateHelperStatus: "failed", errorCode: "installer_exit_\(proc.terminationStatus)")
      }
      DispatchQueue.main.async {
        isUpdating = false
        let success = proc.terminationStatus == 0
        statusWindow.showCompleted(
          success: success,
          message: success ? "Restart your editor to use the latest MCP tools." : "Run the Drafted installer again from drafted.live/install."
        )
      }
    }
    do { try process.run() } catch {
      isUpdating = false
      Telemetry.report(event: "drafted_update_helper_failed", updateHelperStatus: "failed", errorCode: "process_run_failed")
      statusWindow.showCompleted(success: false, message: error.localizedDescription)
      return
    }
  }

  static func viewLogs() {
    let fm = FileManager.default
    let home = fm.homeDirectoryForCurrentUser
    let fallback = home.appendingPathComponent(".drafted")
    try? fm.createDirectory(at: fallback, withIntermediateDirectories: true)
    let readme = fallback.appendingPathComponent("logs-readme.txt")
    if !fm.fileExists(atPath: readme.path) {
      let text = """
Drafted MCP logs are written by the host app that launched drafted-mcp.

Most useful locations:
- Drafted MCP client errors: ~/.drafted/mcp-client.log
- Claude Desktop: ~/Library/Logs/Claude/mcp-server-drafted.log
- Claude Code: ~/.claude/projects (session transcripts) or ~/.claude/logs when present
- Drafted installer/updater: ~/.drafted

If a tool returns `fetch failed`, open the Claude log or transcript from the same run and look for `mcp-server-drafted` or `Drafted MCP`.
"""
      try? text.write(to: readme, atomically: true, encoding: .utf8)
    }

    let candidates = [
      home.appendingPathComponent(".drafted/mcp-client.log"),
      home.appendingPathComponent("Library/Logs/Claude/mcp-server-drafted.log"),
      home.appendingPathComponent("Library/Logs/Claude"),
      home.appendingPathComponent(".claude/logs"),
      home.appendingPathComponent(".claude/projects"),
      fallback,
    ]
    for url in candidates where fm.fileExists(atPath: url.path) {
      var isDirectory: ObjCBool = false
      fm.fileExists(atPath: url.path, isDirectory: &isDirectory)
      if isDirectory.boolValue {
        NSWorkspace.shared.open(url)
      } else {
        NSWorkspace.shared.activateFileViewerSelecting([url])
      }
      return
    }
  }

  static func openDrafted() {
    NSWorkspace.shared.open(URL(string: "https://drafted.live")!)
  }
}

struct Telemetry {
  static func report(event: String, updateHelperStatus: String, errorCode: String? = nil) {
    guard ProcessInfo.processInfo.environment["DRAFTED_TELEMETRY"] != "0" else { return }
    let path = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".drafted/install.json")
    guard
      let data = try? Data(contentsOf: path),
      let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
      json["telemetry"] as? Bool != false,
      let installId = json["installId"] as? String
    else { return }
    var body: [String: Any] = [
      "installId": installId,
      "event": event,
      "schemaVersion": 1,
      "osFamily": "macos",
      "osVersion": ProcessInfo.processInfo.operatingSystemVersionString,
      "arch": SystemVersion.machine,
      "updateHelperStatus": updateHelperStatus,
      "source": "macos-helper"
    ]
    if let errorCode = errorCode { body["errorCode"] = errorCode }
    guard
      let url = URL(string: "https://drafted.live/api/installations/report"),
      let payload = try? JSONSerialization.data(withJSONObject: body)
    else { return }
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = payload
    URLSession.shared.dataTask(with: request).resume()
  }
}

enum SystemVersion {
  static var machine: String {
    #if arch(arm64)
    return "arm64"
    #elseif arch(x86_64)
    return "x64"
    #else
    return "unknown"
    #endif
  }
}
SWIFT

  cat > "$app_bundle/Contents/Info.plist" <<APPPLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>CFBundleExecutable</key>
  <string>DraftedUpdater</string>
  <key>CFBundleIdentifier</key>
  <string>live.drafted.updater</string>
  <key>CFBundleName</key>
  <string>Drafted Updater</string>
  <key>CFBundleDisplayName</key>
  <string>Drafted Updater</string>
  <key>CFBundlePackageType</key>
  <string>APPL</string>
  <key>CFBundleShortVersionString</key>
  <string>1.0</string>
  <key>LSMinimumSystemVersion</key>
  <string>13.0</string>
  <key>LSUIElement</key>
  <true/>
  <key>NSHighResolutionCapable</key>
  <true/>
</dict>
</plist>
APPPLIST

  if ! swiftc -parse-as-library "$swift_file" -o "$exe_path" -framework SwiftUI -framework AppKit >/dev/null 2>&1; then
    echo -e "    ${YELLOW}Could not build macOS menu bar updater; skipping.${RESET}"
    return 0
  fi
  codesign -s - -f "$exe_path" >/dev/null 2>&1 || true

  cat > "$plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>live.drafted.updater</string>
  <key>ProgramArguments</key>
  <array>
    <string>open</string>
    <string>-a</string>
    <string>$app_bundle</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <false/>
</dict>
</plist>
PLIST

  launchctl bootout "gui/$uid" "$plist" >/dev/null 2>&1 || true
  pkill -x DraftedUpdater >/dev/null 2>&1 || true
  mdimport "$app_bundle" >/dev/null 2>&1 || true
  open "$app_bundle" >/dev/null 2>&1 || true
  ok "macOS menu bar updater"
}
if [ "$INSTALL_MODE" = "production" ]; then
  step "Installing update helper"
  install_update_menu_icon
fi
verify_no_legacy_http_config
report_telemetry "drafted_mcp_configured" "installed"
report_telemetry "drafted_install" "installed"

# ── Done ─────────────────────────────────────────────────────────

echo ""
echo ""
echo -e "${GREEN}${BOLD}You're all set!${RESET}"
echo ""
echo -e "  ${DIM}MCP name:${RESET}  ${BOLD}$INSTALL_NAME${RESET}"
echo -e "  ${DIM}Server:${RESET}    ${BOLD}$INSTALL_SERVER${RESET}"
echo -e "  ${DIM}To update production:${RESET}  rerun curl -fsSL https://drafted.live/install.sh | bash"
echo -e "  ${DIM}To uninstall:${RESET}  npm uninstall -g drafted --prefix ~/.drafted/npm-global && rm -rf ~/.drafted"
echo ""
echo -e "${YELLOW}${BOLD}"
echo "  ┌─────────────────────────────────────────────────────────┐"
echo "  │                                                         │"
echo "  │   >>>  RESTART YOUR EDITOR TO ACTIVATE DRAFTED  <<<     │"
echo "  │                                                         │"
echo "  │   Close and reopen Claude Desktop, Claude Code,         │"
echo "  │   Codex, or Cursor so it picks up the new MCP server.   │"
echo "  │                                                         │"
echo "  └─────────────────────────────────────────────────────────┘"
echo -e "${RESET}"
