drafted 1.24.2 → 1.24.4

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