drafted 1.18.0 → 1.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/install-mcp.sh ADDED
@@ -0,0 +1,1269 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Drafted — Shared surface for AI-human collaboration
5
+ # This script installs the Drafted MCP server and CLI globally via npm,
6
+ # then registers it with Claude Desktop, Claude Code, Codex, and Cursor.
7
+ #
8
+ # Run with:
9
+ # curl -fsSL https://drafted.live/install.sh | bash
10
+
11
+ SERVER="https://drafted.live"
12
+ INSTALLER_VERSION="1"
13
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+ INSTALL_MODE="production"
15
+ INSTALL_NAME="${DRAFTED_MCP_NAME:-drafted}"
16
+ INSTALL_SERVER="${DRAFTED_SERVER:-$SERVER}"
17
+ INSTALL_AUTH_FILE="${DRAFTED_AUTH_FILE:-}"
18
+
19
+ while [ $# -gt 0 ]; do
20
+ case "$1" in
21
+ --local)
22
+ INSTALL_MODE="local"
23
+ INSTALL_NAME="${DRAFTED_MCP_NAME:-drafted-local}"
24
+ INSTALL_SERVER="${DRAFTED_SERVER:-http://localhost:3477}"
25
+ INSTALL_AUTH_FILE="${DRAFTED_AUTH_FILE:-$HOME/.drafted/auth.local.json}"
26
+ if [ -z "${DRAFTED_TELEMETRY+x}" ]; then export DRAFTED_TELEMETRY=0; fi
27
+ shift
28
+ ;;
29
+ --server)
30
+ INSTALL_SERVER="$2"
31
+ shift 2
32
+ ;;
33
+ --name)
34
+ INSTALL_NAME="$2"
35
+ shift 2
36
+ ;;
37
+ --auth-file)
38
+ INSTALL_AUTH_FILE="$2"
39
+ shift 2
40
+ ;;
41
+ --help|-h)
42
+ echo "Usage: install-mcp.sh [--local] [--server URL] [--name MCP_NAME] [--auth-file PATH]"
43
+ echo " default: installs production MCP named drafted -> https://drafted.live"
44
+ echo " --local: installs duplicate MCP named drafted-local -> http://localhost:3477"
45
+ exit 0
46
+ ;;
47
+ *)
48
+ echo "Unknown option: $1" >&2
49
+ exit 1
50
+ ;;
51
+ esac
52
+ done
53
+
54
+
55
+ BOLD="\033[1m"
56
+ DIM="\033[2m"
57
+ GREEN="\033[32m"
58
+ YELLOW="\033[33m"
59
+ RED="\033[31m"
60
+ CYAN="\033[36m"
61
+ RESET="\033[0m"
62
+
63
+ step=0
64
+ step() {
65
+ step=$((step + 1))
66
+ echo ""
67
+ echo -e "${CYAN}[$step]${RESET} ${BOLD}$1${RESET}"
68
+ }
69
+
70
+ ok() {
71
+ echo -e " ${GREEN}✓${RESET} $1"
72
+ }
73
+
74
+ fail() {
75
+ echo -e " ${RED}✗${RESET} $1"
76
+ }
77
+
78
+ init_telemetry() {
79
+ INSTALL_ID=""
80
+ TELEMETRY_ENABLED=true
81
+ if [ "${DRAFTED_TELEMETRY:-}" = "0" ]; then
82
+ TELEMETRY_ENABLED=false
83
+ return 0
84
+ fi
85
+ mkdir -p "$HOME/.drafted"
86
+ INSTALL_ID="$(node - "$HOME/.drafted/install.json" <<'NODE'
87
+ const fs = require('fs');
88
+ const crypto = require('crypto');
89
+ const p = process.argv[2];
90
+ let data = {};
91
+ try { data = JSON.parse(fs.readFileSync(p, 'utf8')); } catch {}
92
+ if (data.telemetry === false) process.exit(2);
93
+ if (!/^[0-9a-f-]{36}$/i.test(String(data.installId || ''))) data.installId = crypto.randomUUID();
94
+ data.telemetry = data.telemetry !== false;
95
+ data.updatedAt = new Date().toISOString();
96
+ fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
97
+ process.stdout.write(data.installId);
98
+ NODE
99
+ )" || TELEMETRY_ENABLED=false
100
+ if [ "$TELEMETRY_ENABLED" = true ]; then
101
+ echo -e " ${DIM}Drafted sends anonymous install telemetry. Set DRAFTED_TELEMETRY=0 to opt out.${RESET}"
102
+ fi
103
+ }
104
+
105
+ report_telemetry() {
106
+ [ "${TELEMETRY_ENABLED:-false}" = true ] || return 0
107
+ [ -n "${INSTALL_ID:-}" ] || return 0
108
+ local event="$1"
109
+ local helper_status="${2:-installed}"
110
+ node - "$SERVER" "$INSTALL_ID" "$event" "$INSTALLER_VERSION" "$helper_status" \
111
+ "${CLIENT_CLAUDE_DESKTOP:-false}" "${CLIENT_CLAUDE_CODE:-false}" "${CLIENT_CODEX:-false}" "${CLIENT_CURSOR:-false}" <<'NODE' >/dev/null 2>&1 || true
112
+ const [server, installId, event, installerVersion, updateHelperStatus, claudeDesktop, claudeCode, codex, cursor] = process.argv.slice(2);
113
+ const os = require('os');
114
+ const cp = require('child_process');
115
+ function run(cmd) { try { return cp.execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().slice(0, 80); } catch { return undefined; } }
116
+ const platform = os.platform();
117
+ const osFamily = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : platform === 'linux' ? 'linux' : 'unknown';
118
+ fetch(`${server}/api/installations/report`, {
119
+ method: 'POST',
120
+ headers: { 'Content-Type': 'application/json', 'User-Agent': 'Drafted Installer' },
121
+ body: JSON.stringify({
122
+ installId,
123
+ event,
124
+ schemaVersion: 1,
125
+ installerVersion,
126
+ cliVersion: run('drafted --version'),
127
+ osFamily,
128
+ osVersion: os.release(),
129
+ arch: os.arch(),
130
+ nodeVersion: process.version,
131
+ npmVersion: run('npm --version'),
132
+ clientsConfigured: {
133
+ claudeDesktop: claudeDesktop === 'true',
134
+ claudeCode: claudeCode === 'true',
135
+ codex: codex === 'true',
136
+ cursor: cursor === 'true'
137
+ },
138
+ updateHelperStatus,
139
+ mcpMode: 'stdio',
140
+ source: 'installer'
141
+ })
142
+ }).catch(() => {});
143
+ NODE
144
+ }
145
+
146
+ install_portable_node() {
147
+ local os arch platform filename url tmp node_dir
148
+ os="$(uname -s)"
149
+ arch="$(uname -m)"
150
+ case "$os" in
151
+ Darwin) platform="darwin" ;;
152
+ Linux) platform="linux" ;;
153
+ *) fail "Unsupported OS for automatic Node.js install: $os"; exit 1 ;;
154
+ esac
155
+ case "$arch" in
156
+ x86_64|amd64) arch="x64" ;;
157
+ arm64|aarch64) arch="arm64" ;;
158
+ *) fail "Unsupported CPU for automatic Node.js install: $arch"; exit 1 ;;
159
+ esac
160
+
161
+ mkdir -p "$HOME/.drafted"
162
+ node_dir="$HOME/.drafted/node"
163
+ tmp="$(mktemp -d)"
164
+ filename="$(curl -fsSL https://nodejs.org/dist/latest-v22.x/SHASUMS256.txt | awk "/node-v.*-${platform}-${arch}\\.tar\\.xz/ {print \$2; exit}")"
165
+ if [ -z "$filename" ]; then
166
+ fail "Could not find Node.js 22 download for $platform-$arch"
167
+ exit 1
168
+ fi
169
+ url="https://nodejs.org/dist/latest-v22.x/$filename"
170
+ echo -e " ${YELLOW}Downloading Node.js 22 for $platform-$arch...${RESET}"
171
+ curl -fsSL "$url" -o "$tmp/node.tar.xz"
172
+ rm -rf "$node_dir"
173
+ mkdir -p "$node_dir"
174
+ tar -xJf "$tmp/node.tar.xz" -C "$node_dir" --strip-components=1
175
+ rm -rf "$tmp"
176
+ export PATH="$node_dir/bin:$PATH"
177
+ ok "Installed portable Node.js $(node -v)"
178
+ }
179
+
180
+ # ── Welcome ───────────────────────────────────────────────────────
181
+
182
+ echo ""
183
+ echo -e "${BOLD}Welcome to Drafted${RESET}"
184
+ echo -e "This will set up Drafted so your agents can create work on a shared surface."
185
+ echo -e "It only takes a minute."
186
+ if [ "$INSTALL_MODE" = "local" ]; then
187
+ echo -e "${DIM}Local mode: installing MCP ${BOLD}$INSTALL_NAME${RESET}${DIM} -> $INSTALL_SERVER without touching production drafted.${RESET}"
188
+ fi
189
+
190
+ # ── Prerequisites ─────────────────────────────────────────────────
191
+
192
+ step "Checking your system"
193
+
194
+ # Node.js
195
+ if command -v node &>/dev/null; then
196
+ NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
197
+ if [ "$NODE_VERSION" -lt 22 ]; then
198
+ echo -e " ${YELLOW}Node.js $(node -v) is too old; installing Node.js 22 for Drafted.${RESET}"
199
+ install_portable_node
200
+ else
201
+ ok "Node.js $(node -v)"
202
+ fi
203
+ else
204
+ echo -e " ${YELLOW}Node.js is not installed; installing Node.js 22 for Drafted.${RESET}"
205
+ install_portable_node
206
+ fi
207
+
208
+ init_telemetry
209
+
210
+ NPM_GLOBAL_PREFIX="$HOME/.drafted/npm-global"
211
+ mkdir -p "$NPM_GLOBAL_PREFIX"
212
+
213
+ # A `drafted` resolved elsewhere on PATH (a leftover install from before this
214
+ # fixed-prefix scheme, or a manual `npm i -g` under the default prefix) would
215
+ # permanently shadow the fixed-prefix copy: the updater keeps this prefix
216
+ # current, but the shell keeps running the stale one. Remove it at its source.
217
+ STALE_DRAFTED_BIN="$(command -v drafted 2>/dev/null || true)"
218
+ if [ -n "$STALE_DRAFTED_BIN" ]; then
219
+ # Derived from where the resolved binary actually sits (bin's grandparent),
220
+ # not `npm config get prefix` — that reflects our own target prefix once
221
+ # it's already been set on a prior run, which would make this a no-op on
222
+ # every machine that's already installed once (i.e. every real machine).
223
+ CANDIDATE_PREFIX="$(dirname "$(dirname "$STALE_DRAFTED_BIN")")"
224
+ if [ "$CANDIDATE_PREFIX" != "$NPM_GLOBAL_PREFIX" ] && [ -d "$CANDIDATE_PREFIX/lib/node_modules/drafted" ]; then
225
+ echo -e " ${YELLOW}Found a stale Drafted install at $CANDIDATE_PREFIX — removing it so it can't shadow the current one.${RESET}"
226
+ npm uninstall -g drafted --prefix "$CANDIDATE_PREFIX" >/dev/null 2>&1 || true
227
+ fi
228
+ fi
229
+
230
+ # Do NOT `npm config set prefix` — that repoints the user's GLOBAL npm prefix, so every
231
+ # `npm install -g <pkg>` they ever run lands in our dir AND our uninstall (`rm -rf ~/.drafted`)
232
+ # would wipe all their other globals. Install drafted with an explicit per-command --prefix
233
+ # instead (below), and HEAL any global prefix pin an older version of this installer wrote so
234
+ # the user's default is restored. Preserve every other ~/.npmrc line (auth tokens, etc.).
235
+ NPMRC="${npm_config_userconfig:-$HOME/.npmrc}"
236
+ if [ -f "$NPMRC" ] && grep -Eq '^[[:space:]]*prefix[[:space:]]*=.*/\.drafted/npm-global/?[[:space:]]*$' "$NPMRC"; then
237
+ tmp_npmrc="$(mktemp)"
238
+ grep -Ev '^[[:space:]]*prefix[[:space:]]*=.*/\.drafted/npm-global/?[[:space:]]*$' "$NPMRC" > "$tmp_npmrc" && cat "$tmp_npmrc" > "$NPMRC"
239
+ rm -f "$tmp_npmrc"
240
+ fi
241
+ export PATH="$NPM_GLOBAL_PREFIX/bin:$PATH"
242
+
243
+ # Persist the prefix's bin dir at the FRONT of PATH for future shells — without
244
+ # this, npm's prefix config makes `npm install -g` land in the right place, but
245
+ # a bare `drafted` in a new terminal keeps resolving through whatever was
246
+ # already on PATH (see the installedVersion() comment on the login-shell PATH
247
+ # gap below). Idempotent guarded block, same pattern as the agent-instructions
248
+ # markers further down.
249
+ persist_npm_global_path() {
250
+ local marker_begin="# BEGIN drafted-path"
251
+ local marker_end="# END drafted-path"
252
+ local line="export PATH=\"$NPM_GLOBAL_PREFIX/bin:\$PATH\""
253
+ local rc
254
+ case "$(basename "${SHELL:-}")" in
255
+ zsh) rc="$HOME/.zshrc" ;;
256
+ bash) rc="$HOME/.bash_profile" ;;
257
+ *) rc="$HOME/.profile" ;;
258
+ esac
259
+ [ -f "$rc" ] || : > "$rc"
260
+ grep -qF "$marker_begin" "$rc" 2>/dev/null && return 0
261
+ { echo ""; echo "$marker_begin"; echo "$line"; echo "$marker_end"; } >> "$rc"
262
+ }
263
+ persist_npm_global_path
264
+
265
+ # ── Install ───────────────────────────────────────────────────────
266
+
267
+ step "Installing Drafted"
268
+
269
+ # Retry with back-off: a fresh `npm publish` can briefly 404/ETARGET on the CDN
270
+ # edge before the tarball propagates, so an updater that fires the instant a new
271
+ # version lands would otherwise report a spurious "update failed". Ride it out.
272
+ install_drafted_pkg() {
273
+ attempts=5; delay=4; n=1
274
+ while :; do
275
+ if npm install -g drafted@latest --force --prefix "$NPM_GLOBAL_PREFIX"; then return 0; fi
276
+ if [ "$n" -ge "$attempts" ]; then return 1; fi
277
+ 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}"
278
+ sleep "$delay"
279
+ n=$((n + 1)); delay=$((delay * 2))
280
+ done
281
+ }
282
+ if ! install_drafted_pkg; then
283
+ echo -e " ${RED}npm install -g drafted@latest failed after multiple attempts.${RESET}"
284
+ exit 1
285
+ fi
286
+ hash -r 2>/dev/null || true
287
+ NPM_ROOT="$(npm root -g --prefix "$NPM_GLOBAL_PREFIX" 2>/dev/null || true)"
288
+ MCP_SERVER_MODULE="$NPM_ROOT/drafted/mcp/server.mjs"
289
+ if [ -n "$NPM_ROOT" ] && [ -f "$MCP_SERVER_MODULE" ]; then
290
+ 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"
291
+ elif [ "${DRAFTED_HEADLESS:-}" = "1" ]; then
292
+ echo -e " ${DIM}Skipping package import check in headless installer test.${RESET}"
293
+ else
294
+ echo -e " ${RED}Could not find installed MCP server module at $MCP_SERVER_MODULE${RESET}"
295
+ exit 1
296
+ fi
297
+ ok "Installed $(drafted --version 2>/dev/null || echo 'drafted') via npm"
298
+
299
+ # ── Configure ─────────────────────────────────────────────────────
300
+
301
+ step "Connecting to your tools"
302
+
303
+ # Write server URL config
304
+ mkdir -p "$HOME/.drafted"
305
+ if [ "$INSTALL_MODE" = "production" ]; then
306
+ echo "{\"server\":\"$INSTALL_SERVER\"}" > "$HOME/.drafted/config.json"
307
+ ok "Server: $INSTALL_SERVER"
308
+ else
309
+ ok "Local server: $INSTALL_SERVER"
310
+ fi
311
+
312
+ # Local stdio MCP command. Production uses the installed package; --local uses this checkout when available.
313
+ if [ "$INSTALL_MODE" = "local" ] && [ -f "$SCRIPT_DIR/mcp/server.mjs" ]; then
314
+ DRAFTED_MCP_COMMAND="$(command -v node)"
315
+ DRAFTED_MCP_ARGS_JSON="$(node -e 'console.log(JSON.stringify([process.argv[1]]))' "$SCRIPT_DIR/mcp/server.mjs")"
316
+ else
317
+ DRAFTED_MCP_COMMAND="$(command -v drafted-mcp)"
318
+ DRAFTED_MCP_ARGS_JSON="[]"
319
+ fi
320
+
321
+ configure_mcp() {
322
+ local config_path="$1"
323
+ local label="$2"
324
+ local config_dir
325
+ config_dir="$(dirname "$config_path")"
326
+ mkdir -p "$config_dir"
327
+
328
+ node -e "
329
+ const fs = require('fs');
330
+ const p = process.argv[1];
331
+ const name = process.argv[2];
332
+ const command = process.argv[3];
333
+ const args = JSON.parse(process.argv[4]);
334
+ const server = process.argv[5];
335
+ const authFile = process.argv[6];
336
+ const mode = process.argv[7];
337
+ let c = {};
338
+ try { c = JSON.parse(fs.readFileSync(p, 'utf8')); } catch {}
339
+ if (!c.mcpServers) c.mcpServers = {};
340
+ const env = { DRAFTED_SERVER: server };
341
+ if (authFile) env.DRAFTED_AUTH_FILE = authFile;
342
+ if (mode === 'local') env.DRAFTED_TELEMETRY = '0';
343
+ c.mcpServers[name] = { command, args, env };
344
+ fs.writeFileSync(p, JSON.stringify(c, null, 2) + '\n');
345
+ " "$config_path" "$INSTALL_NAME" "$DRAFTED_MCP_COMMAND" "$DRAFTED_MCP_ARGS_JSON" "$INSTALL_SERVER" "$INSTALL_AUTH_FILE" "$INSTALL_MODE"
346
+ ok "$label"
347
+ }
348
+
349
+
350
+ verify_no_legacy_http_config() {
351
+ local found=false
352
+ for file in "$CLAUDE_DESKTOP_CONFIG" "$CLAUDE_CODE_CONFIG" "$CURSOR_CONFIG" "$CODEX_CONFIG"; do
353
+ [ -f "$file" ] || continue
354
+ if grep -q "https://drafted.live/mcp" "$file" 2>/dev/null; then
355
+ found=true
356
+ echo -e " ${RED}✗${RESET} Legacy HTTP Drafted MCP still present in $file"
357
+ fi
358
+ done
359
+ if [ "$found" = true ]; then
360
+ fail "Installer migration incomplete. Remove legacy https://drafted.live/mcp entries and rerun."
361
+ exit 1
362
+ fi
363
+ }
364
+
365
+ configure_codex() {
366
+ local config_path="$1"
367
+ local label="$2"
368
+ local config_dir
369
+ config_dir="$(dirname "$config_path")"
370
+ mkdir -p "$config_dir"
371
+
372
+ node - "$config_path" "$INSTALL_NAME" "$DRAFTED_MCP_COMMAND" "$DRAFTED_MCP_ARGS_JSON" "$INSTALL_SERVER" "$INSTALL_AUTH_FILE" "$INSTALL_MODE" <<'NODE'
373
+ const fs = require('fs');
374
+ const p = process.argv[2];
375
+ const mcpName = process.argv[3];
376
+ const command = process.argv[4];
377
+ const args = JSON.parse(process.argv[5]);
378
+ const server = process.argv[6];
379
+ const authFile = process.argv[7];
380
+ const mode = process.argv[8];
381
+
382
+ let text = '';
383
+ try { text = fs.readFileSync(p, 'utf8'); } catch {}
384
+
385
+ const lines = text.split(/\r?\n/);
386
+ const out = [];
387
+ let skip = false;
388
+
389
+ for (const line of lines) {
390
+ const section = line.match(/^\[(.+)\]$/);
391
+ if (section) {
392
+ const name = section[1].trim();
393
+ if (name === `mcp_servers.${mcpName}` || name === `mcp_servers.${mcpName}.env`) {
394
+ skip = true;
395
+ continue;
396
+ }
397
+ if (skip) skip = false;
398
+ }
399
+ if (!skip) out.push(line);
400
+ }
401
+
402
+ let next = out.join('\n').replace(/\s+$/, '');
403
+ if (next) next += '\n\n';
404
+ next += `[mcp_servers.${mcpName}]\n`;
405
+ next += 'command = ' + JSON.stringify(command) + '\n';
406
+ next += 'args = [' + args.map(a => JSON.stringify(a)).join(', ') + ']\n';
407
+ next += `\n[mcp_servers.${mcpName}.env]\n`;
408
+ next += 'DRAFTED_SERVER = ' + JSON.stringify(server) + '\n';
409
+ if (authFile) next += 'DRAFTED_AUTH_FILE = ' + JSON.stringify(authFile) + '\n';
410
+ if (mode === 'local') next += 'DRAFTED_TELEMETRY = "0"\n';
411
+ next += '\n';
412
+
413
+ fs.writeFileSync(p, next);
414
+ NODE
415
+
416
+ ok "$label"
417
+ }
418
+
419
+ CLAUDE_DESKTOP_CONFIG="$HOME/Library/Application Support/Claude/claude_desktop_config.json"
420
+ CLAUDE_CODE_CONFIG="$HOME/.claude.json"
421
+ CODEX_CONFIG="$HOME/.codex/config.toml"
422
+ CURSOR_CONFIG="$HOME/.cursor/mcp.json"
423
+
424
+ CONFIGURED=false
425
+ CLIENT_CLAUDE_DESKTOP=false
426
+ CLIENT_CLAUDE_CODE=false
427
+ CLIENT_CODEX=false
428
+ CLIENT_CURSOR=false
429
+
430
+ # Claude Desktop
431
+ if [ -d "/Applications/Claude.app" ] || [ -d "$HOME/Applications/Claude.app" ] || [ -f "$CLAUDE_DESKTOP_CONFIG" ]; then
432
+ configure_mcp "$CLAUDE_DESKTOP_CONFIG" "Claude Desktop"
433
+ CLIENT_CLAUDE_DESKTOP=true
434
+ CONFIGURED=true
435
+ fi
436
+
437
+ # Claude Code
438
+ if command -v claude &>/dev/null || [ -f "$CLAUDE_CODE_CONFIG" ] || [ -d "$HOME/.claude" ]; then
439
+ configure_mcp "$CLAUDE_CODE_CONFIG" "Claude Code"
440
+ CLIENT_CLAUDE_CODE=true
441
+ CONFIGURED=true
442
+ fi
443
+
444
+ # Codex
445
+ if command -v codex &>/dev/null || [ -d "$HOME/.codex" ]; then
446
+ configure_codex "$CODEX_CONFIG" "Codex"
447
+ CLIENT_CODEX=true
448
+ CONFIGURED=true
449
+ fi
450
+
451
+ # Cursor
452
+ if [ -d "/Applications/Cursor.app" ] || [ -d "$HOME/Applications/Cursor.app" ] || command -v cursor &>/dev/null || [ -f "$CURSOR_CONFIG" ]; then
453
+ configure_mcp "$CURSOR_CONFIG" "Cursor"
454
+ CLIENT_CURSOR=true
455
+ CONFIGURED=true
456
+ fi
457
+
458
+ # If nothing detected, pre-configure all
459
+ if [ "$CONFIGURED" = false ]; then
460
+ echo -e " ${YELLOW}No supported editors detected — pre-configuring all.${RESET}"
461
+ configure_mcp "$CLAUDE_DESKTOP_CONFIG" "Claude Desktop (pre-configured)"
462
+ configure_mcp "$CLAUDE_CODE_CONFIG" "Claude Code (pre-configured)"
463
+ configure_codex "$CODEX_CONFIG" "Codex (pre-configured)"
464
+ configure_mcp "$CURSOR_CONFIG" "Cursor (pre-configured)"
465
+ CLIENT_CLAUDE_DESKTOP=true
466
+ CLIENT_CLAUDE_CODE=true
467
+ CLIENT_CODEX=true
468
+ CLIENT_CURSOR=true
469
+ fi
470
+
471
+ # ── Agent instructions ────────────────────────────────────────────
472
+
473
+ step "Installing global agent instructions"
474
+
475
+ load_global_agent_instructions() {
476
+ local local_path="$SCRIPT_DIR/agent-instructions/global.md"
477
+ if [ -f "$local_path" ]; then
478
+ cat "$local_path"
479
+ return 0
480
+ fi
481
+ cat <<'DRAFTED_GLOBAL_INSTRUCTIONS'
482
+ <drafted>
483
+ You have Drafted MCP tools — a shared surface for durable, reviewable work: produce substantive output as frames on the surface (not only in chat), put knowledge in the org wiki, and encode repeatable methods as skills. The full operating manual is the `drafted` skill installed with the plugin — follow it when working with Drafted. Before writing, verify the org/project echoed in the response is the one you intend.
484
+ </drafted>
485
+ DRAFTED_GLOBAL_INSTRUCTIONS
486
+ }
487
+
488
+ install_agent_instructions() {
489
+ local instructions_path="$1"
490
+ local label="$2"
491
+ local instructions_dir
492
+ local body
493
+ instructions_dir="$(dirname "$instructions_path")"
494
+ mkdir -p "$instructions_dir"
495
+
496
+ if ! body="$(load_global_agent_instructions)"; then
497
+ fail "Could not load Drafted global instructions"
498
+ return 1
499
+ fi
500
+
501
+ DRAFTED_GLOBAL_INSTRUCTIONS_BODY="$body" node - "$instructions_path" <<'NODE'
502
+ const fs = require('fs');
503
+ const p = process.argv[2];
504
+ const begin = '<!-- BEGIN drafted-global-instructions -->';
505
+ const end = '<!-- END drafted-global-instructions -->';
506
+ const body = process.env.DRAFTED_GLOBAL_INSTRUCTIONS_BODY || '';
507
+ if (!body.trim()) throw new Error('Drafted global instructions are empty');
508
+ const block = `${begin}\n${body.replace(/\s+$/, '')}\n${end}`;
509
+ let text = '';
510
+ try { text = fs.readFileSync(p, 'utf8'); } catch {}
511
+ const escapeRe = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
512
+ const re = new RegExp(`${escapeRe(begin)}[\\s\\S]*?${escapeRe(end)}`);
513
+ let next;
514
+ if (re.test(text)) {
515
+ next = text.replace(re, block);
516
+ } else {
517
+ next = text.replace(/\s+$/, '');
518
+ if (next) next += '\n\n';
519
+ next += block + '\n';
520
+ }
521
+ fs.writeFileSync(p, next.endsWith('\n') ? next : next + '\n');
522
+ NODE
523
+
524
+ ok "$label"
525
+ }
526
+
527
+ install_agent_instructions "$HOME/.claude/CLAUDE.md" "Claude global instructions"
528
+ install_agent_instructions "$HOME/.codex/CODEX.md" "Codex global instructions"
529
+
530
+ # ── Skills & commands ─────────────────────────────────────────────
531
+
532
+ step "Installing skills and commands"
533
+
534
+ # Resolve the plugin source: a local checkout when present (e.g. --local from this
535
+ # repo), otherwise the installed npm package's bundled plugin/ directory.
536
+ # require.resolve() misses packages under the custom global prefix
537
+ # ($HOME/.drafted/npm-global) when run from an arbitrary cwd (curl | bash), so
538
+ # prefer the already-computed global node_modules root from the install step.
539
+ if [ -n "${NPM_ROOT:-}" ] && [ -d "$NPM_ROOT/drafted" ]; then
540
+ DRAFTED_PKG_DIR="$NPM_ROOT/drafted"
541
+ else
542
+ DRAFTED_PKG_DIR="$(node -e "try { console.log(require.resolve('drafted/package.json').replace('/package.json','')) } catch { process.exit(1) }" 2>/dev/null)" || true
543
+ fi
544
+ if [ -d "$SCRIPT_DIR/plugin/skills" ] || [ -d "$SCRIPT_DIR/plugin/commands" ]; then
545
+ PLUGIN_SRC="$SCRIPT_DIR/plugin"
546
+ elif [ -n "$DRAFTED_PKG_DIR" ] && [ -d "$DRAFTED_PKG_DIR/plugin" ]; then
547
+ PLUGIN_SRC="$DRAFTED_PKG_DIR/plugin"
548
+ else
549
+ PLUGIN_SRC=""
550
+ fi
551
+
552
+ # Skills — Claude Code auto-loads these from ~/.claude/skills/<name>/SKILL.md
553
+ if [ -n "$PLUGIN_SRC" ] && [ -d "$PLUGIN_SRC/skills" ]; then
554
+ SKILLS_DIR="$HOME/.claude/skills"
555
+ mkdir -p "$SKILLS_DIR"
556
+ SKILL_COUNT=0
557
+ for d in "$PLUGIN_SRC/skills/"*/; do
558
+ [ -d "$d" ] || continue
559
+ DIRNAME="$(basename "$d")"
560
+ rm -rf "$SKILLS_DIR/$DIRNAME"
561
+ cp -r "$d" "$SKILLS_DIR/$DIRNAME"
562
+ SKILL_COUNT=$((SKILL_COUNT + 1))
563
+ done
564
+ ok "Installed $SKILL_COUNT skill(s) to $SKILLS_DIR"
565
+ else
566
+ echo -e " ${YELLOW}Skill source not found in package — skipping skills.${RESET}"
567
+ fi
568
+
569
+ # Commands — namespaced under drafted/ so they register as /drafted:<name>,
570
+ # matching the marketplace install and the commands' own cross-references.
571
+ if [ -n "$PLUGIN_SRC" ] && [ -d "$PLUGIN_SRC/commands" ]; then
572
+ CMD_COUNT=0
573
+ for f in "$PLUGIN_SRC/commands/"*.md; do
574
+ [ -f "$f" ] || continue
575
+ CMD_COUNT=$((CMD_COUNT + 1))
576
+ done
577
+
578
+ if [ "$CLIENT_CLAUDE_CODE" = true ]; then
579
+ CLAUDE_CMD_DIR="$HOME/.claude/commands/drafted"
580
+ rm -rf "$CLAUDE_CMD_DIR"
581
+ mkdir -p "$CLAUDE_CMD_DIR"
582
+ cp "$PLUGIN_SRC/commands/"*.md "$CLAUDE_CMD_DIR/" 2>/dev/null || true
583
+ ok "Installed $CMD_COUNT command(s) to $CLAUDE_CMD_DIR"
584
+ fi
585
+
586
+ if [ "$CLIENT_CODEX" = true ]; then
587
+ CODEX_CMD_DIR="$HOME/.codex/prompts/drafted"
588
+ rm -rf "$CODEX_CMD_DIR"
589
+ mkdir -p "$CODEX_CMD_DIR"
590
+ cp "$PLUGIN_SRC/commands/"*.md "$CODEX_CMD_DIR/" 2>/dev/null || true
591
+ ok "Installed $CMD_COUNT command(s) to $CODEX_CMD_DIR"
592
+ fi
593
+ else
594
+ echo -e " ${YELLOW}Command source not found in package — skipping commands.${RESET}"
595
+ fi
596
+
597
+
598
+ # ── Update menu bar helper ───────────────────────────────────────
599
+
600
+ # Idempotently remove the legacy Swift menu-bar updater so it can neither run alongside the
601
+ # new desktop app nor be resurrected at next login. Safe to call when nothing legacy exists.
602
+ retire_legacy_updater() {
603
+ local uid legacy_plist
604
+ uid="$(id -u)"
605
+ legacy_plist="$HOME/Library/LaunchAgents/live.drafted.updater.plist"
606
+ launchctl bootout "gui/$uid/live.drafted.updater" >/dev/null 2>&1 || true
607
+ if [ -f "$legacy_plist" ]; then
608
+ launchctl bootout "gui/$uid" "$legacy_plist" >/dev/null 2>&1 || true
609
+ fi
610
+ rm -f "$legacy_plist" >/dev/null 2>&1 || true
611
+ pkill -x DraftedUpdater >/dev/null 2>&1 || true
612
+ rm -rf "/Applications/Drafted Updater.app" "$HOME/Applications/Drafted Updater.app" >/dev/null 2>&1 || true
613
+ }
614
+
615
+ # Install (or detect) the Tauri desktop app that replaces the legacy menu-bar updater.
616
+ # Returns 0 ONLY when a valid Drafted.app is present afterward, so the caller can safely
617
+ # retire the legacy updater. Returns 1 when no new bundle is available yet — the caller then
618
+ # falls back to building the legacy Swift updater, so users are never left without a tray.
619
+ install_desktop_app() {
620
+ local install_dir app_bundle app_exe uid plist tmp tarball url
621
+ install_dir="/Applications"
622
+ [ -w "$install_dir" ] || install_dir="$HOME/Applications"
623
+ mkdir -p "$install_dir"
624
+ app_bundle="$install_dir/Drafted.app"
625
+
626
+ if [ -n "${DRAFTED_DESKTOP_APP:-}" ] && [ -d "${DRAFTED_DESKTOP_APP}" ]; then
627
+ # Local prebuilt bundle — lets us test the cutover before the CDN endpoint exists.
628
+ rm -rf "$app_bundle" >/dev/null 2>&1 || true
629
+ ditto "${DRAFTED_DESKTOP_APP}" "$app_bundle" >/dev/null 2>&1 || true
630
+ else
631
+ # Signed bundle tarball (expects Drafted.app at the archive root). Until this endpoint is
632
+ # published the request 404s and we fall through to legacy — that is the cutover gate.
633
+ url="${DRAFTED_DESKTOP_URL:-$INSTALL_SERVER/desktop/Drafted-macos.tar.gz}"
634
+ tmp="$(mktemp -d)"
635
+ tarball="$tmp/Drafted-macos.tar.gz"
636
+ if curl -fsSL --max-time 120 "$url" -o "$tarball" >/dev/null 2>&1; then
637
+ rm -rf "$app_bundle" >/dev/null 2>&1 || true
638
+ tar -xzf "$tarball" -C "$install_dir" >/dev/null 2>&1 || true
639
+ fi
640
+ rm -rf "$tmp" >/dev/null 2>&1 || true
641
+ fi
642
+
643
+ # Validate: a real bundle whose declared executable exists. The bundle dir is Drafted.app
644
+ # (productName) but the binary is the Cargo bin name (drafted-desktop), so read the actual
645
+ # name from Info.plist rather than assuming. If absent/invalid, signal "not available yet".
646
+ local exe_name
647
+ exe_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app_bundle/Contents/Info.plist" 2>/dev/null || true)"
648
+ app_exe="$app_bundle/Contents/MacOS/$exe_name"
649
+ if [ -z "$exe_name" ] || [ ! -x "$app_exe" ]; then
650
+ return 1
651
+ fi
652
+
653
+ # Clear quarantine so Gatekeeper doesn't block a freshly downloaded bundle.
654
+ xattr -dr com.apple.quarantine "$app_bundle" >/dev/null 2>&1 || true
655
+
656
+ # Register run-at-login for the new app (mirrors the legacy launch agent). Write the
657
+ # plist BEFORE touching launchd so the bootstrap below loads the new binary path.
658
+ uid="$(id -u)"
659
+ plist="$HOME/Library/LaunchAgents/live.drafted.desktop.plist"
660
+ mkdir -p "$HOME/Library/LaunchAgents"
661
+ cat > "$plist" <<PLIST
662
+ <?xml version="1.0" encoding="UTF-8"?>
663
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
664
+ <plist version="1.0">
665
+ <dict>
666
+ <key>Label</key>
667
+ <string>live.drafted.desktop</string>
668
+ <key>ProgramArguments</key>
669
+ <array>
670
+ <string>$app_exe</string>
671
+ </array>
672
+ <key>RunAtLoad</key>
673
+ <true/>
674
+ <key>KeepAlive</key>
675
+ <true/>
676
+ </dict>
677
+ </plist>
678
+ PLIST
679
+ # Restart under launchd DETERMINISTICALLY. bootout is async: bootstrapping before the old
680
+ # service is fully torn down races, and when it loses the label is left UNLOADED — the app
681
+ # dies with no KeepAlive to save it (that bricked the in-app updater, which is itself a child
682
+ # of the very app being replaced). And a bare pkill while the service is still loaded just
683
+ # trips KeepAlive into respawning the old binary mid-swap. So: bootout, POLL until the label
684
+ # is actually gone, kill any stray non-launchd instance, bootstrap the new plist, and
685
+ # kickstart -k to guarantee it comes up (RunAtLoad alone is not proof it started).
686
+ local svc="gui/$uid/live.drafted.desktop"
687
+ launchctl bootout "$svc" >/dev/null 2>&1 || true
688
+ local waited=0
689
+ while [ "$waited" -lt 40 ] && launchctl print "$svc" >/dev/null 2>&1; do
690
+ sleep 0.25
691
+ waited=$((waited + 1))
692
+ done
693
+ pkill -x "$exe_name" >/dev/null 2>&1 || true
694
+ launchctl bootstrap "gui/$uid" "$plist" >/dev/null 2>&1 || true
695
+ launchctl kickstart -k "$svc" >/dev/null 2>&1 || true
696
+ return 0
697
+ }
698
+
699
+ install_update_menu_icon() {
700
+ if [ "${DRAFTED_HEADLESS:-}" = "1" ]; then
701
+ echo -e " ${YELLOW}Headless mode: skipping update menu icon.${RESET}"
702
+ return 0
703
+ fi
704
+ if [ "$(uname -s)" != "Darwin" ]; then
705
+ echo -e " ${YELLOW}Update menu icon is only installed on macOS by this script.${RESET}"
706
+ return 0
707
+ fi
708
+
709
+ # Prefer the Tauri desktop app. If it installs (or is already present and valid), retire the
710
+ # legacy Swift updater so the two can never run side by side or be resurrected at next login.
711
+ if install_desktop_app; then
712
+ retire_legacy_updater
713
+ ok "Drafted desktop app"
714
+ return 0
715
+ fi
716
+ echo -e " ${YELLOW}Desktop app bundle not available yet; using the menu bar updater.${RESET}"
717
+
718
+ if ! command -v swiftc >/dev/null 2>&1; then
719
+ echo -e " ${YELLOW}Swift compiler not found; skipping macOS menu bar updater.${RESET}"
720
+ return 0
721
+ fi
722
+
723
+ local work_dir install_dir app_bundle app_macos app_resources swift_file exe_path plist_dir plist uid logo_path legacy_bundle
724
+ work_dir="$HOME/.drafted/updater"
725
+ install_dir="/Applications"
726
+ if [ ! -w "$install_dir" ]; then
727
+ install_dir="$HOME/Applications"
728
+ fi
729
+ app_bundle="$install_dir/Drafted Updater.app"
730
+ app_macos="$app_bundle/Contents/MacOS"
731
+ app_resources="$app_bundle/Contents/Resources"
732
+ swift_file="$work_dir/DraftedUpdater.swift"
733
+ exe_path="$app_macos/DraftedUpdater"
734
+ plist_dir="$HOME/Library/LaunchAgents"
735
+ plist="$plist_dir/live.drafted.updater.plist"
736
+ uid="$(id -u)"
737
+ legacy_bundle="$work_dir/DraftedUpdater.app"
738
+ mkdir -p "$work_dir" "$install_dir" "$app_macos" "$app_resources" "$plist_dir"
739
+ rm -rf "$legacy_bundle"
740
+
741
+ logo_path="$app_resources/logo.svg"
742
+ if [ -f "$SCRIPT_DIR/server/vendor/logo.svg" ]; then
743
+ cp "$SCRIPT_DIR/server/vendor/logo.svg" "$logo_path"
744
+ else
745
+ curl -fsSL "$INSTALL_SERVER/vendor/logo.svg" -o "$logo_path" >/dev/null 2>&1 || true
746
+ fi
747
+ if [ -f "$logo_path" ]; then
748
+ perl -0pi -e 's/fill="currentColor"/fill="#FFFFFF"/g; s/fill="var\(--logo-letter, #[^)]+\)"/fill="#0A2540"/g' "$logo_path" >/dev/null 2>&1 || true
749
+ fi
750
+
751
+ cat > "$swift_file" <<'SWIFT'
752
+ import SwiftUI
753
+ import AppKit
754
+
755
+ @main
756
+ struct DraftedUpdaterApp: App {
757
+ @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
758
+
759
+ init() {
760
+ Telemetry.report(event: "drafted_update_helper_started", updateHelperStatus: "running")
761
+ Telemetry.report(event: "drafted_heartbeat", updateHelperStatus: "running")
762
+ }
763
+
764
+ var body: some Scene {
765
+ MenuBarExtra {
766
+ Button("Check for Updates…") { Actions.checkForUpdate(userInitiated: true) }
767
+ Button("Update Drafted") { Actions.updateDrafted() }
768
+ Button("View Logs") { Actions.viewLogs() }
769
+ Button("Open Drafted") { Actions.openDrafted() }
770
+ Divider()
771
+ Button("Quit") { NSApp.terminate(nil) }
772
+ } label: {
773
+ if let image = Actions.logoImage() {
774
+ Image(nsImage: image)
775
+ .resizable()
776
+ .frame(width: 16, height: 18)
777
+ .accessibilityLabel("Drafted")
778
+ } else {
779
+ Text("Drafted")
780
+ }
781
+ }
782
+ .menuBarExtraStyle(.menu)
783
+ }
784
+ }
785
+
786
+ // Drives the genuine auto-update: a background check shortly after login and
787
+ // every 6 hours while the menu-bar helper is resident. When a newer published
788
+ // version exists (and the user hasn't skipped it), it prompts Update / Skip.
789
+ final class AppDelegate: NSObject, NSApplicationDelegate {
790
+ private var timer: Timer?
791
+
792
+ func applicationDidFinishLaunching(_ notification: Notification) {
793
+ DispatchQueue.main.asyncAfter(deadline: .now() + 25) {
794
+ Actions.checkForUpdate(userInitiated: false)
795
+ }
796
+ let t = Timer(timeInterval: 6 * 3600, repeats: true) { _ in
797
+ Actions.checkForUpdate(userInitiated: false)
798
+ }
799
+ RunLoop.main.add(t, forMode: .common)
800
+ timer = t
801
+ }
802
+ }
803
+
804
+
805
+ final class UpdateStatusWindow: NSWindowController {
806
+ private let stack = NSStackView()
807
+ private let spinner = NSProgressIndicator()
808
+ private let titleField = NSTextField(labelWithString: "Updating Drafted…")
809
+ private let bodyField = NSTextField(labelWithString: "Downloading and running the installer. This can take a minute.")
810
+ private let closeButton = NSButton(title: "OK", target: nil, action: nil)
811
+
812
+ init() {
813
+ let panel = NSPanel(
814
+ contentRect: NSRect(x: 0, y: 0, width: 380, height: 168),
815
+ styleMask: [.titled, .closable],
816
+ backing: .buffered,
817
+ defer: false
818
+ )
819
+ panel.title = "Drafted Update"
820
+ panel.isReleasedWhenClosed = false
821
+ panel.level = .floating
822
+ super.init(window: panel)
823
+
824
+ stack.orientation = .vertical
825
+ stack.alignment = .centerX
826
+ stack.spacing = 12
827
+ stack.translatesAutoresizingMaskIntoConstraints = false
828
+
829
+ spinner.style = .spinning
830
+ spinner.controlSize = .regular
831
+
832
+ titleField.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
833
+ titleField.alignment = .center
834
+
835
+ bodyField.font = NSFont.systemFont(ofSize: 13)
836
+ bodyField.textColor = .secondaryLabelColor
837
+ bodyField.alignment = .center
838
+ bodyField.maximumNumberOfLines = 3
839
+ bodyField.lineBreakMode = .byWordWrapping
840
+
841
+ closeButton.target = self
842
+ closeButton.action = #selector(closeClicked)
843
+ closeButton.isHidden = true
844
+
845
+ stack.addArrangedSubview(spinner)
846
+ stack.addArrangedSubview(titleField)
847
+ stack.addArrangedSubview(bodyField)
848
+ stack.addArrangedSubview(closeButton)
849
+
850
+ panel.contentView = NSView()
851
+ panel.contentView?.addSubview(stack)
852
+ NSLayoutConstraint.activate([
853
+ stack.leadingAnchor.constraint(equalTo: panel.contentView!.leadingAnchor, constant: 28),
854
+ stack.trailingAnchor.constraint(equalTo: panel.contentView!.trailingAnchor, constant: -28),
855
+ stack.centerYAnchor.constraint(equalTo: panel.contentView!.centerYAnchor)
856
+ ])
857
+ }
858
+
859
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
860
+
861
+ func showUpdating() {
862
+ titleField.stringValue = "Updating Drafted…"
863
+ bodyField.stringValue = "Downloading and running the installer. This can take a minute."
864
+ closeButton.isHidden = true
865
+ spinner.isHidden = false
866
+ spinner.startAnimation(nil)
867
+ NSApp.activate(ignoringOtherApps: true)
868
+ showWindow(nil)
869
+ window?.center()
870
+ }
871
+
872
+ func showCompleted(success: Bool, message: String) {
873
+ spinner.stopAnimation(nil)
874
+ spinner.isHidden = true
875
+ titleField.stringValue = success ? "Update completed" : "Update failed"
876
+ bodyField.stringValue = message
877
+ closeButton.isHidden = false
878
+ NSApp.activate(ignoringOtherApps: true)
879
+ showWindow(nil)
880
+ window?.center()
881
+ }
882
+
883
+ @objc private func closeClicked() { window?.close() }
884
+ }
885
+
886
+ struct Actions {
887
+ static let home = FileManager.default.homeDirectoryForCurrentUser
888
+
889
+ static func logoImage() -> NSImage? {
890
+ guard
891
+ let url = Bundle.main.url(forResource: "logo", withExtension: "svg"),
892
+ let image = NSImage(contentsOf: url)
893
+ else { return nil }
894
+ image.size = NSSize(width: 16, height: 18)
895
+ image.isTemplate = false
896
+ return image
897
+ }
898
+
899
+ private static var isUpdating = false
900
+ private static var updateWindow: UpdateStatusWindow?
901
+
902
+ // MARK: - Auto-update check
903
+
904
+ // Compare the installed CLI version to npm's published `latest`. Prompt only
905
+ // when newer and not skipped (background), or always report (user-initiated).
906
+ static func checkForUpdate(userInitiated: Bool) {
907
+ let installed = installedVersion()
908
+ latestVersion { latest in
909
+ DispatchQueue.main.async {
910
+ guard let latest = latest else {
911
+ if userInitiated {
912
+ showInfo(title: "Couldn’t check for updates", body: "Please check your connection and try again.")
913
+ }
914
+ return
915
+ }
916
+ guard let installed = installed, isNewer(latest, than: installed) else {
917
+ if userInitiated {
918
+ showInfo(title: "Drafted is up to date", body: "You’re on \(installed ?? "the latest") version.")
919
+ }
920
+ return
921
+ }
922
+ if !userInitiated && skippedVersion() == latest { return }
923
+ presentUpdateAlert(latest: latest, installed: installed)
924
+ }
925
+ }
926
+ }
927
+
928
+ static func presentUpdateAlert(latest: String, installed: String) {
929
+ Telemetry.report(event: "drafted_update_available", updateHelperStatus: "running")
930
+ let alert = NSAlert()
931
+ alert.messageText = "Drafted \(latest) is available"
932
+ alert.informativeText = "You have \(installed). Update now? Your editor needs to be restarted afterward to load the new MCP tools."
933
+ alert.addButton(withTitle: "Update")
934
+ alert.addButton(withTitle: "Skip This Version")
935
+ alert.addButton(withTitle: "Later")
936
+ if let logo = logoImage() { alert.icon = logo }
937
+ NSApp.activate(ignoringOtherApps: true)
938
+ switch alert.runModal() {
939
+ case .alertFirstButtonReturn:
940
+ updateDrafted()
941
+ case .alertSecondButtonReturn:
942
+ setSkippedVersion(latest)
943
+ Telemetry.report(event: "drafted_update_skipped", updateHelperStatus: "running")
944
+ default:
945
+ break
946
+ }
947
+ }
948
+
949
+ static func showInfo(title: String, body: String) {
950
+ let alert = NSAlert()
951
+ alert.messageText = title
952
+ alert.informativeText = body
953
+ alert.addButton(withTitle: "OK")
954
+ if let logo = logoImage() { alert.icon = logo }
955
+ NSApp.activate(ignoringOtherApps: true)
956
+ alert.runModal()
957
+ }
958
+
959
+ // Installed version, resolved WITHOUT depending on the login-shell PATH —
960
+ // the installer's npm prefix (~/.drafted/npm-global) is never written to any
961
+ // login profile, so `bash -lc "drafted"` finds nothing. Read the installed
962
+ // package.json at the fixed prefix first (the source npm itself writes), then
963
+ // fall back to the absolute binary, then a login shell (custom prefixes on PATH).
964
+ static func installedVersion() -> String? {
965
+ let pkg = home.appendingPathComponent(".drafted/npm-global/lib/node_modules/drafted/package.json")
966
+ if
967
+ let data = try? Data(contentsOf: pkg),
968
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
969
+ let version = json["version"] as? String,
970
+ isSemver(version)
971
+ { return version }
972
+
973
+ let bin = home.appendingPathComponent(".drafted/npm-global/bin/drafted").path
974
+ for command in ["\"\(bin)\" --version 2>/dev/null", "drafted --version 2>/dev/null"] {
975
+ let process = Process()
976
+ process.executableURL = URL(fileURLWithPath: "/bin/bash")
977
+ process.arguments = ["-lc", command]
978
+ let pipe = Pipe()
979
+ process.standardOutput = pipe
980
+ process.standardError = FileHandle.nullDevice
981
+ do { try process.run(); process.waitUntilExit() } catch { continue }
982
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
983
+ if let out = String(data: data, encoding: .utf8), let version = firstSemver(in: out) {
984
+ return version
985
+ }
986
+ }
987
+ return nil
988
+ }
989
+
990
+ static func latestVersion(_ completion: @escaping (String?) -> Void) {
991
+ guard let url = URL(string: "https://registry.npmjs.org/drafted/latest") else { completion(nil); return }
992
+ var request = URLRequest(url: url)
993
+ request.timeoutInterval = 15
994
+ URLSession.shared.dataTask(with: request) { data, _, _ in
995
+ guard
996
+ let data = data,
997
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
998
+ let version = json["version"] as? String
999
+ else { completion(nil); return }
1000
+ completion(version)
1001
+ }.resume()
1002
+ }
1003
+
1004
+ static func skippedVersion() -> String? {
1005
+ let path = home.appendingPathComponent(".drafted/install.json")
1006
+ guard
1007
+ let data = try? Data(contentsOf: path),
1008
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
1009
+ else { return nil }
1010
+ return json["skippedVersion"] as? String
1011
+ }
1012
+
1013
+ static func setSkippedVersion(_ version: String) {
1014
+ let path = home.appendingPathComponent(".drafted/install.json")
1015
+ var json = ((try? Data(contentsOf: path)).flatMap { try? JSONSerialization.jsonObject(with: $0) } as? [String: Any]) ?? [:]
1016
+ json["skippedVersion"] = version
1017
+ if let out = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) {
1018
+ try? out.write(to: path)
1019
+ }
1020
+ }
1021
+
1022
+ // First dotted-numeric token in the text, e.g. "1.11.7" from "drafted 1.11.7".
1023
+ static func firstSemver(in text: String) -> String? {
1024
+ var current = ""
1025
+ for ch in text {
1026
+ if ch.isNumber || ch == "." {
1027
+ current.append(ch)
1028
+ } else {
1029
+ if isSemver(current) { return current }
1030
+ current = ""
1031
+ }
1032
+ }
1033
+ return isSemver(current) ? current : nil
1034
+ }
1035
+
1036
+ static func isSemver(_ s: String) -> Bool {
1037
+ let parts = s.split(separator: ".")
1038
+ return parts.count >= 2 && parts.allSatisfy { !$0.isEmpty && $0.allSatisfy { $0.isNumber } }
1039
+ }
1040
+
1041
+ // Numeric, dot-separated comparison (our versions have no prerelease tags).
1042
+ static func isNewer(_ a: String, than b: String) -> Bool {
1043
+ let pa = a.split(separator: ".").map { Int($0) ?? 0 }
1044
+ let pb = b.split(separator: ".").map { Int($0) ?? 0 }
1045
+ for i in 0..<max(pa.count, pb.count) {
1046
+ let x = i < pa.count ? pa[i] : 0
1047
+ let y = i < pb.count ? pb[i] : 0
1048
+ if x != y { return x > y }
1049
+ }
1050
+ return false
1051
+ }
1052
+
1053
+ static func updateDrafted() {
1054
+ if isUpdating {
1055
+ updateWindow?.showUpdating()
1056
+ return
1057
+ }
1058
+
1059
+ isUpdating = true
1060
+ let statusWindow = UpdateStatusWindow()
1061
+ updateWindow = statusWindow
1062
+ statusWindow.showUpdating()
1063
+
1064
+ let command = "tmp=$(mktemp); curl -fsSL https://drafted.live/install.sh -o \"$tmp\" && bash \"$tmp\""
1065
+ let process = Process()
1066
+ process.executableURL = URL(fileURLWithPath: "/bin/bash")
1067
+ process.arguments = ["-lc", command]
1068
+ process.terminationHandler = { proc in
1069
+ if proc.terminationStatus != 0 {
1070
+ Telemetry.report(event: "drafted_update_helper_failed", updateHelperStatus: "failed", errorCode: "installer_exit_\(proc.terminationStatus)")
1071
+ }
1072
+ DispatchQueue.main.async {
1073
+ isUpdating = false
1074
+ let success = proc.terminationStatus == 0
1075
+ statusWindow.showCompleted(
1076
+ success: success,
1077
+ message: success ? "Restart your editor to use the latest MCP tools." : "Run the Drafted installer again from drafted.live/install."
1078
+ )
1079
+ }
1080
+ }
1081
+ do { try process.run() } catch {
1082
+ isUpdating = false
1083
+ Telemetry.report(event: "drafted_update_helper_failed", updateHelperStatus: "failed", errorCode: "process_run_failed")
1084
+ statusWindow.showCompleted(success: false, message: error.localizedDescription)
1085
+ return
1086
+ }
1087
+ }
1088
+
1089
+ static func viewLogs() {
1090
+ let fm = FileManager.default
1091
+ let home = fm.homeDirectoryForCurrentUser
1092
+ let fallback = home.appendingPathComponent(".drafted")
1093
+ try? fm.createDirectory(at: fallback, withIntermediateDirectories: true)
1094
+ let readme = fallback.appendingPathComponent("logs-readme.txt")
1095
+ if !fm.fileExists(atPath: readme.path) {
1096
+ let text = """
1097
+ Drafted MCP logs are written by the host app that launched drafted-mcp.
1098
+
1099
+ Most useful locations:
1100
+ - Drafted MCP client errors: ~/.drafted/mcp-client.log
1101
+ - Claude Desktop: ~/Library/Logs/Claude/mcp-server-drafted.log
1102
+ - Claude Code: ~/.claude/projects (session transcripts) or ~/.claude/logs when present
1103
+ - Drafted installer/updater: ~/.drafted
1104
+
1105
+ 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`.
1106
+ """
1107
+ try? text.write(to: readme, atomically: true, encoding: .utf8)
1108
+ }
1109
+
1110
+ let candidates = [
1111
+ home.appendingPathComponent(".drafted/mcp-client.log"),
1112
+ home.appendingPathComponent("Library/Logs/Claude/mcp-server-drafted.log"),
1113
+ home.appendingPathComponent("Library/Logs/Claude"),
1114
+ home.appendingPathComponent(".claude/logs"),
1115
+ home.appendingPathComponent(".claude/projects"),
1116
+ fallback,
1117
+ ]
1118
+ for url in candidates where fm.fileExists(atPath: url.path) {
1119
+ var isDirectory: ObjCBool = false
1120
+ fm.fileExists(atPath: url.path, isDirectory: &isDirectory)
1121
+ if isDirectory.boolValue {
1122
+ NSWorkspace.shared.open(url)
1123
+ } else {
1124
+ NSWorkspace.shared.activateFileViewerSelecting([url])
1125
+ }
1126
+ return
1127
+ }
1128
+ }
1129
+
1130
+ static func openDrafted() {
1131
+ NSWorkspace.shared.open(URL(string: "https://drafted.live")!)
1132
+ }
1133
+ }
1134
+
1135
+ struct Telemetry {
1136
+ static func report(event: String, updateHelperStatus: String, errorCode: String? = nil) {
1137
+ guard ProcessInfo.processInfo.environment["DRAFTED_TELEMETRY"] != "0" else { return }
1138
+ let path = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".drafted/install.json")
1139
+ guard
1140
+ let data = try? Data(contentsOf: path),
1141
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1142
+ json["telemetry"] as? Bool != false,
1143
+ let installId = json["installId"] as? String
1144
+ else { return }
1145
+ var body: [String: Any] = [
1146
+ "installId": installId,
1147
+ "event": event,
1148
+ "schemaVersion": 1,
1149
+ "osFamily": "macos",
1150
+ "osVersion": ProcessInfo.processInfo.operatingSystemVersionString,
1151
+ "arch": SystemVersion.machine,
1152
+ "updateHelperStatus": updateHelperStatus,
1153
+ "source": "macos-helper"
1154
+ ]
1155
+ if let errorCode = errorCode { body["errorCode"] = errorCode }
1156
+ guard
1157
+ let url = URL(string: "https://drafted.live/api/installations/report"),
1158
+ let payload = try? JSONSerialization.data(withJSONObject: body)
1159
+ else { return }
1160
+ var request = URLRequest(url: url)
1161
+ request.httpMethod = "POST"
1162
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
1163
+ request.httpBody = payload
1164
+ URLSession.shared.dataTask(with: request).resume()
1165
+ }
1166
+ }
1167
+
1168
+ enum SystemVersion {
1169
+ static var machine: String {
1170
+ #if arch(arm64)
1171
+ return "arm64"
1172
+ #elseif arch(x86_64)
1173
+ return "x64"
1174
+ #else
1175
+ return "unknown"
1176
+ #endif
1177
+ }
1178
+ }
1179
+ SWIFT
1180
+
1181
+ cat > "$app_bundle/Contents/Info.plist" <<APPPLIST
1182
+ <?xml version="1.0" encoding="UTF-8"?>
1183
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1184
+ <plist version="1.0">
1185
+ <dict>
1186
+ <key>CFBundleExecutable</key>
1187
+ <string>DraftedUpdater</string>
1188
+ <key>CFBundleIdentifier</key>
1189
+ <string>live.drafted.updater</string>
1190
+ <key>CFBundleName</key>
1191
+ <string>Drafted Updater</string>
1192
+ <key>CFBundleDisplayName</key>
1193
+ <string>Drafted Updater</string>
1194
+ <key>CFBundlePackageType</key>
1195
+ <string>APPL</string>
1196
+ <key>CFBundleShortVersionString</key>
1197
+ <string>1.0</string>
1198
+ <key>LSMinimumSystemVersion</key>
1199
+ <string>13.0</string>
1200
+ <key>LSUIElement</key>
1201
+ <true/>
1202
+ <key>NSHighResolutionCapable</key>
1203
+ <true/>
1204
+ </dict>
1205
+ </plist>
1206
+ APPPLIST
1207
+
1208
+ if ! swiftc -parse-as-library "$swift_file" -o "$exe_path" -framework SwiftUI -framework AppKit >/dev/null 2>&1; then
1209
+ echo -e " ${YELLOW}Could not build macOS menu bar updater; skipping.${RESET}"
1210
+ return 0
1211
+ fi
1212
+ codesign -s - -f "$exe_path" >/dev/null 2>&1 || true
1213
+
1214
+ cat > "$plist" <<PLIST
1215
+ <?xml version="1.0" encoding="UTF-8"?>
1216
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1217
+ <plist version="1.0">
1218
+ <dict>
1219
+ <key>Label</key>
1220
+ <string>live.drafted.updater</string>
1221
+ <key>ProgramArguments</key>
1222
+ <array>
1223
+ <string>open</string>
1224
+ <string>-a</string>
1225
+ <string>$app_bundle</string>
1226
+ </array>
1227
+ <key>RunAtLoad</key>
1228
+ <true/>
1229
+ <key>KeepAlive</key>
1230
+ <false/>
1231
+ </dict>
1232
+ </plist>
1233
+ PLIST
1234
+
1235
+ launchctl bootout "gui/$uid" "$plist" >/dev/null 2>&1 || true
1236
+ pkill -x DraftedUpdater >/dev/null 2>&1 || true
1237
+ mdimport "$app_bundle" >/dev/null 2>&1 || true
1238
+ open "$app_bundle" >/dev/null 2>&1 || true
1239
+ ok "macOS menu bar updater"
1240
+ }
1241
+ if [ "$INSTALL_MODE" = "production" ]; then
1242
+ step "Installing update helper"
1243
+ install_update_menu_icon
1244
+ fi
1245
+ verify_no_legacy_http_config
1246
+ report_telemetry "drafted_mcp_configured" "installed"
1247
+ report_telemetry "drafted_install" "installed"
1248
+
1249
+ # ── Done ─────────────────────────────────────────────────────────
1250
+
1251
+ echo ""
1252
+ echo ""
1253
+ echo -e "${GREEN}${BOLD}You're all set!${RESET}"
1254
+ echo ""
1255
+ echo -e " ${DIM}MCP name:${RESET} ${BOLD}$INSTALL_NAME${RESET}"
1256
+ echo -e " ${DIM}Server:${RESET} ${BOLD}$INSTALL_SERVER${RESET}"
1257
+ echo -e " ${DIM}To update production:${RESET} rerun curl -fsSL https://drafted.live/install.sh | bash"
1258
+ echo -e " ${DIM}To uninstall:${RESET} npm uninstall -g drafted --prefix ~/.drafted/npm-global && rm -rf ~/.drafted"
1259
+ echo ""
1260
+ echo -e "${YELLOW}${BOLD}"
1261
+ echo " ┌─────────────────────────────────────────────────────────┐"
1262
+ echo " │ │"
1263
+ echo " │ >>> RESTART YOUR EDITOR TO ACTIVATE DRAFTED <<< │"
1264
+ echo " │ │"
1265
+ echo " │ Close and reopen Claude Desktop, Claude Code, │"
1266
+ echo " │ Codex, or Cursor so it picks up the new MCP server. │"
1267
+ echo " │ │"
1268
+ echo " └─────────────────────────────────────────────────────────┘"
1269
+ echo -e "${RESET}"