drafted 1.1.0 → 1.1.1

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,192 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Drafted — Design workspace for AI agents
5
+ # This script installs the Drafted MCP server and CLI globally via npm,
6
+ # then registers it with Claude Desktop, Claude Code, and Cursor.
7
+ #
8
+ # Run with:
9
+ # curl -fsSL https://drafted.live/install.sh | bash
10
+
11
+ SERVER="https://drafted.live"
12
+
13
+ BOLD="\033[1m"
14
+ DIM="\033[2m"
15
+ GREEN="\033[32m"
16
+ YELLOW="\033[33m"
17
+ RED="\033[31m"
18
+ CYAN="\033[36m"
19
+ RESET="\033[0m"
20
+
21
+ step=0
22
+ step() {
23
+ step=$((step + 1))
24
+ echo ""
25
+ echo -e "${CYAN}[$step]${RESET} ${BOLD}$1${RESET}"
26
+ }
27
+
28
+ ok() {
29
+ echo -e " ${GREEN}✓${RESET} $1"
30
+ }
31
+
32
+ fail() {
33
+ echo -e " ${RED}✗${RESET} $1"
34
+ }
35
+
36
+ # ── Welcome ───────────────────────────────────────────────────────
37
+
38
+ echo ""
39
+ echo -e "${BOLD}Welcome to Drafted${RESET}"
40
+ echo -e "This will set up Drafted so Claude can create designs for you."
41
+ echo -e "It only takes a minute."
42
+
43
+ # ── Prerequisites ─────────────────────────────────────────────────
44
+
45
+ step "Checking your system"
46
+
47
+ # Node.js
48
+ if command -v node &>/dev/null; then
49
+ NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
50
+ if [ "$NODE_VERSION" -lt 22 ]; then
51
+ fail "Node.js is too old (found $(node -v), need 22+)."
52
+ echo ""
53
+ echo -e " Update it by running: ${BOLD}brew upgrade node${RESET}"
54
+ echo -e " Then re-run this installer."
55
+ exit 1
56
+ fi
57
+ ok "Node.js $(node -v)"
58
+ else
59
+ fail "Node.js is not installed."
60
+ echo ""
61
+ if command -v brew &>/dev/null; then
62
+ echo -e " Install it by running: ${BOLD}brew install node${RESET}"
63
+ else
64
+ echo -e " Download it from: ${BOLD}https://nodejs.org${RESET}"
65
+ fi
66
+ echo -e " Then re-run this installer."
67
+ exit 1
68
+ fi
69
+
70
+ # ── Install ───────────────────────────────────────────────────────
71
+
72
+ step "Installing Drafted"
73
+
74
+ npm install -g drafted@latest --silent 2>/dev/null
75
+ ok "Installed $(drafted --version 2>/dev/null || echo 'drafted') via npm"
76
+
77
+ # ── Configure ─────────────────────────────────────────────────────
78
+
79
+ step "Connecting to your tools"
80
+
81
+ # Write server URL config
82
+ mkdir -p "$HOME/.drafted"
83
+ echo "{\"server\":\"$SERVER\"}" > "$HOME/.drafted/config.json"
84
+ ok "Server: $SERVER"
85
+
86
+ # Resolve the installed binary path for MCP configs
87
+ DRAFTED_MCP_BIN="$(which drafted-mcp 2>/dev/null || echo "drafted-mcp")"
88
+
89
+ configure_mcp() {
90
+ local config_path="$1"
91
+ local label="$2"
92
+ local config_dir
93
+ config_dir="$(dirname "$config_path")"
94
+ mkdir -p "$config_dir"
95
+
96
+ node -e "
97
+ const fs = require('fs');
98
+ const p = process.argv[1];
99
+ let c = {};
100
+ try { c = JSON.parse(fs.readFileSync(p, 'utf8')); } catch {}
101
+ if (!c.mcpServers) c.mcpServers = {};
102
+ c.mcpServers.drafted = {
103
+ command: 'drafted-mcp',
104
+ args: []
105
+ };
106
+ fs.writeFileSync(p, JSON.stringify(c, null, 2) + '\n');
107
+ " "$config_path"
108
+ ok "$label"
109
+ }
110
+
111
+ CLAUDE_DESKTOP_CONFIG="$HOME/Library/Application Support/Claude/claude_desktop_config.json"
112
+ CLAUDE_CODE_CONFIG="$HOME/.claude.json"
113
+ CURSOR_CONFIG="$HOME/.cursor/mcp.json"
114
+
115
+ CONFIGURED=false
116
+
117
+ # Claude Desktop
118
+ if [ -d "/Applications/Claude.app" ] || [ -d "$HOME/Applications/Claude.app" ]; then
119
+ configure_mcp "$CLAUDE_DESKTOP_CONFIG" "Claude Desktop"
120
+ CONFIGURED=true
121
+ fi
122
+
123
+ # Claude Code
124
+ if command -v claude &>/dev/null; then
125
+ configure_mcp "$CLAUDE_CODE_CONFIG" "Claude Code"
126
+ CONFIGURED=true
127
+ fi
128
+
129
+ # Cursor
130
+ if [ -d "/Applications/Cursor.app" ] || [ -d "$HOME/Applications/Cursor.app" ] || command -v cursor &>/dev/null; then
131
+ configure_mcp "$CURSOR_CONFIG" "Cursor"
132
+ CONFIGURED=true
133
+ fi
134
+
135
+ # If nothing detected, pre-configure all
136
+ if [ "$CONFIGURED" = false ]; then
137
+ echo -e " ${YELLOW}No supported editors detected — pre-configuring all.${RESET}"
138
+ configure_mcp "$CLAUDE_DESKTOP_CONFIG" "Claude Desktop (pre-configured)"
139
+ configure_mcp "$CLAUDE_CODE_CONFIG" "Claude Code (pre-configured)"
140
+ configure_mcp "$CURSOR_CONFIG" "Cursor (pre-configured)"
141
+ fi
142
+
143
+ # ── Skills ───────────────────────────────────────────────────────
144
+
145
+ step "Installing skills"
146
+
147
+ # Find the installed package's skills directory
148
+ DRAFTED_PKG_DIR="$(node -e "try { console.log(require.resolve('drafted/package.json').replace('/package.json','')) } catch { process.exit(1) }" 2>/dev/null)" || true
149
+
150
+ if [ -n "$DRAFTED_PKG_DIR" ] && [ -d "$DRAFTED_PKG_DIR/skills" ]; then
151
+ SKILLS_DIR="$HOME/.claude/skills"
152
+ mkdir -p "$SKILLS_DIR"
153
+ INSTALLED=0
154
+ # Copy individual skill files
155
+ for f in "$DRAFTED_PKG_DIR/skills/"*.md; do
156
+ [ -f "$f" ] || continue
157
+ cp "$f" "$SKILLS_DIR/"
158
+ INSTALLED=$((INSTALLED + 1))
159
+ done
160
+ # Copy skill directories
161
+ for d in "$DRAFTED_PKG_DIR/skills/"*/; do
162
+ [ -d "$d" ] || continue
163
+ DIRNAME="$(basename "$d")"
164
+ rm -rf "$SKILLS_DIR/$DIRNAME"
165
+ cp -r "$d" "$SKILLS_DIR/$DIRNAME"
166
+ INSTALLED=$((INSTALLED + 1))
167
+ done
168
+ ok "Installed $INSTALLED skills to $SKILLS_DIR"
169
+ else
170
+ echo -e " ${YELLOW}Skills directory not found in package — skipping.${RESET}"
171
+ fi
172
+
173
+ # ── Done ─────────────────────────────────────────────────────────
174
+
175
+ echo ""
176
+ echo ""
177
+ echo -e "${GREEN}${BOLD}You're all set!${RESET}"
178
+ echo ""
179
+ echo -e " ${DIM}View your designs at:${RESET} ${BOLD}https://drafted.live${RESET}"
180
+ echo -e " ${DIM}To update:${RESET} npm install -g drafted@latest"
181
+ echo -e " ${DIM}To uninstall:${RESET} npm uninstall -g drafted && rm -rf ~/.drafted"
182
+ echo ""
183
+ echo -e "${YELLOW}${BOLD}"
184
+ echo " ┌─────────────────────────────────────────────────────────┐"
185
+ echo " │ │"
186
+ echo " │ >>> RESTART YOUR EDITOR TO ACTIVATE DRAFTED <<< │"
187
+ echo " │ │"
188
+ echo " │ Close and reopen Claude Desktop, Claude Code, │"
189
+ echo " │ or Cursor so it picks up the new MCP server. │"
190
+ echo " │ │"
191
+ echo " └─────────────────────────────────────────────────────────┘"
192
+ echo -e "${RESET}"
package/mcp/server.mjs CHANGED
@@ -14,18 +14,18 @@ import { join, dirname, basename, extname, resolve } from 'path';
14
14
  import { homedir } from 'os';
15
15
  import { fileURLToPath } from 'url';
16
16
  import { z } from 'zod';
17
- import { LAYERS } from '../shared/constants.mjs';
17
+ import { LAYERS } from '../src/shared/constants.mjs';
18
18
 
19
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
20
 
21
21
  const server = new McpServer({
22
22
  name: 'drafted',
23
- version: '2.3.0',
23
+ version: '2.4.0',
24
24
  description: `Multi-tenant design workspace. Structure: Organization → Projects → Layers → Lanes → Frames.
25
25
 
26
26
  An org contains projects. Each project has a zoomable canvas with frames (HTML files) organized as /{layer}/{lane}/{filename}. Layers are predefined categories (wireframes, designs, brand-assets, etc.), lanes are groups within a layer, and frames are the individual design files.
27
27
 
28
- WORKFLOW: list_projects → open_project → ls / → read/write/edit. Every response includes a "project" field showing which project you're operating on always verify it matches your intent before writing.
28
+ WORKFLOW: list_projects → open_project → ls / → read/write/edit. Projects span all orgs -- open_project auto-switches org context. Every response includes a "project" field showing which project you're operating on -- always verify it matches your intent before writing.
29
29
 
30
30
  IMPORTANT: Any URL containing /f/{uuid} is a Drafted frame link — ALWAYS use read(path=URL) to get frame content, focus(target=URL) to pan the canvas to it. Never curl or WebFetch Drafted URLs.`,
31
31
  });
@@ -95,6 +95,16 @@ async function ensureSession() {
95
95
  await cloneSession();
96
96
  }
97
97
 
98
+ const MIME_MAP = {
99
+ '.css': 'text/css', '.js': 'application/javascript', '.mjs': 'application/javascript',
100
+ '.json': 'application/json', '.html': 'text/html', '.htm': 'text/html',
101
+ '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
102
+ '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon',
103
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf', '.eot': 'application/vnd.ms-fontobject',
104
+ '.pdf': 'application/pdf', '.mp4': 'video/mp4', '.webm': 'video/webm',
105
+ };
106
+ function mimeFromExt(ext) { return MIME_MAP[ext?.toLowerCase()] || 'application/octet-stream'; }
107
+
98
108
  async function api(method, path, body, _retried) {
99
109
  await ensureSession();
100
110
  const pid = agentActiveProjectId;
@@ -370,7 +380,7 @@ server.tool('login', 'Authenticate with Drafted. Opens a browser for the user to
370
380
 
371
381
  // ── Project management tools (direct HTTP) ────────────────────────
372
382
 
373
- server.tool('list_projects', 'START HERE. Lists all projects in the org. Use this first to find the project you need, then open_project to switch to it.', {}, async () => {
383
+ server.tool('list_projects', 'START HERE. Lists all projects across all orgs. Use this first to find the project you need, then open_project to switch to it (org switches automatically).', {}, async () => {
374
384
  try {
375
385
  const data = await api('GET', '/api/projects');
376
386
  data.agentProject = agentActiveProjectId || null;
@@ -460,10 +470,19 @@ server.tool('open_project', 'Switch active project. REQUIRED before reading or w
460
470
  if (proj?.slug) projectSlug = proj.slug;
461
471
  } catch { /* fall back to projectId */ }
462
472
  const url = `${base}/project/${projectSlug}`;
463
- const { exec } = await import('child_process');
464
- const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
465
- exec(`${cmd} ${JSON.stringify(url)}`);
466
- return ok({ ...result, url, opened: true });
473
+ // Navigate existing browser tabs instead of opening new ones
474
+ let navigated = 0;
475
+ try {
476
+ const nav = await api('POST', '/api/project/navigate', { projectId });
477
+ navigated = nav.navigated || 0;
478
+ } catch { /* server may not support navigate yet */ }
479
+ // Only open a new tab if no browser tabs were reached
480
+ if (navigated === 0) {
481
+ const { exec } = await import('child_process');
482
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
483
+ exec(`${cmd} ${JSON.stringify(url)}`);
484
+ }
485
+ return ok({ ...result, url, opened: true, navigated });
467
486
  } catch (error) { return err(error); }
468
487
  });
469
488
 
@@ -733,13 +752,20 @@ server.tool('write', 'Write a frame to the ACTIVE PROJECT. Check the "project" f
733
752
 
734
753
  let body;
735
754
  if (file_path) {
736
- // Binary upload: read file from disk, send as base64
737
755
  const resolved = resolve(file_path);
738
756
  if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
739
- const buffer = readFileSync(resolved);
740
757
  const ext = extname(resolved).toLowerCase();
741
- const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf' };
742
- body = { base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
758
+ const TEXT_EXTS = ['.html', '.htm', '.svg', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml'];
759
+ if (TEXT_EXTS.includes(ext)) {
760
+ // Text file: read as content so it's stored inline (enables base tag injection for HTML)
761
+ body = { content: readFileSync(resolved, 'utf8') };
762
+ if (autoSize) body.autoSize = true;
763
+ } else {
764
+ // Binary upload: read file from disk, send as base64
765
+ const buffer = readFileSync(resolved);
766
+ const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf' };
767
+ body = { base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
768
+ }
743
769
  } else {
744
770
  body = { content };
745
771
  if (autoSize) body.autoSize = true;
@@ -853,10 +879,10 @@ server.tool('mv', 'Move/rename a frame within the ACTIVE PROJECT. Response inclu
853
879
 
854
880
  server.tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "project" field. Use open_project first if needed.', {
855
881
  operations: z.array(z.object({
856
- tool: z.enum(['write', 'rm', 'mv', 'edit']).describe('Tool to execute'),
882
+ tool: z.enum(['write', 'rm', 'mv', 'edit', 'upload_asset']).describe('Tool to execute'),
857
883
  path: z.string().optional().describe('Path (for write, rm, edit)'),
858
884
  content: z.string().optional().describe('Content (for write). Mutually exclusive with file_path.'),
859
- file_path: z.string().optional().describe('Absolute path to a local file to upload (for write). Mutually exclusive with content.'),
885
+ file_path: z.string().optional().describe('Absolute path to a local file to upload (for write, upload_asset). Mutually exclusive with content.'),
860
886
  color: z.string().optional().describe('CSS color for frame border (for write)'),
861
887
  from: z.string().optional().describe('Source path (for mv)'),
862
888
  to: z.string().optional().describe('Destination path (for mv)'),
@@ -865,6 +891,9 @@ server.tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes
865
891
  lineHash: z.string(),
866
892
  newContent: z.string().optional(),
867
893
  })).optional().describe('Edit operations (for edit). Always include lineNum.'),
894
+ asset_path: z.string().optional().describe('Relative asset path (for upload_asset, e.g., "css/styles.css")'),
895
+ content_type: z.string().optional().describe('MIME type (for upload_asset, auto-detected if omitted)'),
896
+ frame_id: z.string().optional().describe('Frame ID to associate asset with (for upload_asset)'),
868
897
  })).describe('ALWAYS use batch instead of multiple individual tool calls. Applies the same change to many files, writes multiple frames, or combines writes+edits+deletes. One canvas refresh instead of many. Example: editing 5 wireframes to remove a section = one batch with 5 edit operations.'),
869
898
  }, async ({ operations }) => {
870
899
  try {
@@ -881,20 +910,121 @@ server.tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes
881
910
  }
882
911
 
883
912
  // Resolve file_path → base64 for write operations before sending to server
884
- const resolvedOps = operations.map(op => {
885
- if (op.tool === 'write' && op.file_path) {
886
- const resolved = resolve(op.file_path);
887
- if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
888
- const buffer = readFileSync(resolved);
889
- const ext = extname(resolved).toLowerCase();
890
- const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf' };
891
- const { file_path: _, ...rest } = op;
892
- return { ...rest, base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
913
+ // Separate asset uploads from frame operations
914
+ const assetOps = operations.filter(op => op.tool === 'upload_asset');
915
+ const frameOps = operations.filter(op => op.tool !== 'upload_asset');
916
+
917
+ const results = [];
918
+
919
+ // Handle asset uploads via the asset API
920
+ for (const op of assetOps) {
921
+ try {
922
+ if (!op.asset_path) throw new Error('asset_path is required for upload_asset');
923
+ if (op.asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
924
+
925
+ let b64, ct;
926
+ if (op.file_path) {
927
+ const resolved = resolve(op.file_path);
928
+ if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
929
+ const buffer = readFileSync(resolved);
930
+ b64 = buffer.toString('base64');
931
+ ct = op.content_type || mimeFromExt(extname(resolved));
932
+ } else if (op.content != null) {
933
+ b64 = Buffer.from(op.content).toString('base64');
934
+ ct = op.content_type || mimeFromExt(extname(op.asset_path));
935
+ } else {
936
+ throw new Error('upload_asset requires file_path or content');
937
+ }
938
+
939
+ const body = { base64: b64, contentType: ct };
940
+ if (op.frame_id) body.frameId = op.frame_id;
941
+
942
+ const projectId = agentActiveProjectId;
943
+ if (!projectId) throw new Error('No active project. Call open_project first.');
944
+ const r = await api('PUT', `/api/projects/${projectId}/assets/${op.asset_path}`, body);
945
+ results.push({ ok: true, tool: 'upload_asset', asset_path: op.asset_path, ...r });
946
+ } catch (e) {
947
+ results.push({ ok: false, tool: 'upload_asset', asset_path: op.asset_path, error: e.message });
893
948
  }
894
- return op;
895
- });
949
+ }
896
950
 
897
- const result = await api('POST', '/api/fs/batch', { operations: resolvedOps });
951
+ // Handle frame operations via the batch API
952
+ if (frameOps.length > 0) {
953
+ const TEXT_EXTS = ['.html', '.htm', '.svg', '.md', '.markdown', '.txt', '.css', '.js', '.mjs', '.json', '.xml'];
954
+ const resolvedOps = frameOps.map(op => {
955
+ if (op.tool === 'write' && op.file_path) {
956
+ const resolved = resolve(op.file_path);
957
+ if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
958
+ const ext = extname(resolved).toLowerCase();
959
+ const { file_path: _, ...rest } = op;
960
+ if (TEXT_EXTS.includes(ext)) {
961
+ return { ...rest, content: readFileSync(resolved, 'utf8') };
962
+ }
963
+ const buffer = readFileSync(resolved);
964
+ const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf' };
965
+ return { ...rest, base64: buffer.toString('base64'), contentType: MIME[ext] || 'application/octet-stream' };
966
+ }
967
+ return op;
968
+ });
969
+
970
+ const batchResult = await api('POST', '/api/fs/batch', { operations: resolvedOps });
971
+ if (batchResult.results) results.push(...batchResult.results);
972
+ }
973
+
974
+ return ok({ ok: true, results });
975
+ } catch (error) { return err(error); }
976
+ });
977
+
978
+ // ── Asset tools ──────────────────────────────────────────────────
979
+
980
+ server.tool('upload_asset', 'Upload a supporting file (CSS, JS, image, font) to the ACTIVE PROJECT. Assets are referenced by frames via relative paths — e.g., if your HTML has <link href="css/styles.css">, upload the asset with asset_path="css/styles.css". Assets are NOT frames — they don\'t appear on the canvas. Use batch with upload_asset operations for bulk uploads.', {
981
+ asset_path: z.string().describe('Relative path for the asset (e.g., "css/styles.css", "js/app.js", "img/logo.png"). This must match the path used in HTML references.'),
982
+ file_path: z.string().optional().describe('Absolute path to a local file to upload. Mutually exclusive with content/base64.'),
983
+ content: z.string().optional().describe('Text content (for CSS/JS files). Mutually exclusive with file_path.'),
984
+ base64: z.string().optional().describe('Base64-encoded binary content. Mutually exclusive with file_path and content.'),
985
+ content_type: z.string().optional().describe('MIME type (auto-detected from extension if omitted)'),
986
+ frame_id: z.string().optional().describe('Frame ID to associate this asset with (for cleanup when the frame is deleted). Get this from the write tool response.'),
987
+ }, async ({ asset_path, file_path, content, base64: rawBase64, content_type, frame_id }) => {
988
+ try {
989
+ if (!asset_path) throw new Error('asset_path is required');
990
+ if (asset_path.includes('..')) throw new Error('asset_path must not contain ".."');
991
+
992
+ let b64, ct;
993
+ if (file_path) {
994
+ const resolved = resolve(file_path);
995
+ if (!existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
996
+ const buffer = readFileSync(resolved);
997
+ b64 = buffer.toString('base64');
998
+ ct = content_type || mimeFromExt(extname(resolved));
999
+ } else if (content != null) {
1000
+ b64 = Buffer.from(content).toString('base64');
1001
+ ct = content_type || mimeFromExt(extname(asset_path));
1002
+ } else if (rawBase64) {
1003
+ b64 = rawBase64;
1004
+ ct = content_type || mimeFromExt(extname(asset_path));
1005
+ } else {
1006
+ throw new Error('Provide file_path, content, or base64');
1007
+ }
1008
+
1009
+ const body = { base64: b64, contentType: ct };
1010
+ if (frame_id) body.frameId = frame_id;
1011
+
1012
+ const projectId = agentActiveProjectId;
1013
+ if (!projectId) throw new Error('No active project. Call open_project first.');
1014
+
1015
+ const result = await api('PUT', `/api/projects/${projectId}/assets/${asset_path}`, body);
1016
+ return ok(result);
1017
+ } catch (error) { return err(error); }
1018
+ });
1019
+
1020
+ server.tool('list_assets', 'List all assets in the ACTIVE PROJECT, optionally filtered by frame.', {
1021
+ frame_id: z.string().optional().describe('Filter assets by frame ID'),
1022
+ }, async ({ frame_id }) => {
1023
+ try {
1024
+ const projectId = agentActiveProjectId;
1025
+ if (!projectId) throw new Error('No active project. Call open_project first.');
1026
+ const query = frame_id ? `?frameId=${frame_id}` : '';
1027
+ const result = await api('GET', `/api/projects/${projectId}/assets${query}`);
898
1028
  return ok(result);
899
1029
  } catch (error) { return err(error); }
900
1030
  });
package/package.json CHANGED
@@ -1,38 +1,72 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.1.0",
4
- "description": "Design workspace for AI agents MCP server and CLI",
3
+ "version": "1.1.1",
4
+ "description": "Drafted CLI - Design preview server for Claude agents",
5
5
  "type": "module",
6
+ "files": [
7
+ "cli/",
8
+ "mcp/",
9
+ "src/shared/",
10
+ "install-mcp.sh"
11
+ ],
6
12
  "bin": {
7
13
  "drafted": "./cli/drafted.mjs",
8
14
  "drafted-mcp": "./mcp/server.mjs"
9
15
  },
10
- "files": [
11
- "mcp/",
12
- "cli/",
13
- "shared/",
14
- "skills/",
15
- "README.md"
16
- ],
16
+ "scripts": {
17
+ "dev": "./dev.sh",
18
+ "dev:server": "tsx watch server/server.mjs",
19
+ "dev:local": "node server/server-local.mjs",
20
+ "install-global": "npm install -g .",
21
+ "db:migrate": "psql $DATABASE_URL -f drizzle/migrate.sql",
22
+ "db:push": "drizzle-kit push",
23
+ "db:studio": "drizzle-kit studio",
24
+ "migrate-fs": "tsx server/migrate-fs.ts",
25
+ "db:clean-sessions": "tsx src/auth/cleanup.ts",
26
+ "import:legacy": "tsx scripts/import-legacy.mts",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest"
29
+ },
17
30
  "dependencies": {
31
+ "@aws-sdk/client-s3": "^3.1007.0",
32
+ "@aws-sdk/s3-request-presigner": "^3.1008.0",
18
33
  "@modelcontextprotocol/sdk": "^1.27.1",
19
34
  "commander": "^11.1.0",
35
+ "dagre": "^0.8.5",
36
+ "dotenv": "^17.3.1",
37
+ "drizzle-orm": "^0.45.1",
38
+ "express": "^4.18.2",
39
+ "multer": "^2.1.1",
40
+ "nodemailer": "^8.0.2",
41
+ "pg": "^8.20.0",
42
+ "playwright": "^1.58.2",
43
+ "puppeteer": "^24.37.5",
20
44
  "qrcode-terminal": "^0.12.0",
45
+ "sharp": "^0.34.5",
21
46
  "ws": "^8.16.0",
22
47
  "zod": "^4.3.6"
23
48
  },
24
49
  "keywords": [
25
- "drafted",
26
50
  "design",
27
- "mcp",
28
- "claude",
29
- "ai",
30
- "canvas"
51
+ "canvas",
52
+ "preview",
53
+ "html",
54
+ "claude"
31
55
  ],
32
56
  "author": "ddfourtwo",
33
57
  "repository": {
34
58
  "type": "git",
35
59
  "url": "git+https://github.com/ddfourtwo/drafted.git"
36
60
  },
37
- "license": "MIT"
61
+ "license": "MIT",
62
+ "devDependencies": {
63
+ "@types/express": "^5.0.6",
64
+ "@types/nodemailer": "^7.0.11",
65
+ "@types/pg": "^8.18.0",
66
+ "drizzle-kit": "^0.31.9",
67
+ "tsx": "^4.19.0",
68
+ "typescript": "^5.9.3",
69
+ "vitest": "^3.2.4",
70
+ "yaml": "^2.8.3"
71
+ }
38
72
  }
package/README.md DELETED
@@ -1,32 +0,0 @@
1
- # Drafted
2
-
3
- Design workspace for AI agents. MCP server and CLI for creating and managing designs on a collaborative surface.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install -g drafted
9
- ```
10
-
11
- ## Usage
12
-
13
- ### As an MCP server (for Claude Desktop, Claude Code, Cursor)
14
-
15
- The MCP server is automatically available after install:
16
-
17
- ```bash
18
- drafted-mcp
19
- ```
20
-
21
- ### As a CLI
22
-
23
- ```bash
24
- drafted login
25
- drafted ls
26
- drafted write designs/default/hero.html < design.html
27
- ```
28
-
29
- ## Links
30
-
31
- - **App**: https://drafted.live
32
- - **Docs**: https://drafted.live
@@ -1,317 +0,0 @@
1
- ---
2
- description: Import an existing website into Drafted as a design frame. Renders the page in a headless browser to capture the full DOM (including SPA content), downloads all assets, rewrites paths, then uploads via MCP tools.
3
- argument-hint: "[URL or local path, e.g., 'https://example.com' or './dist']"
4
- ---
5
- # /import-website-to-drafted
6
-
7
- Import a website into Drafted as a fully rendered frame with all its assets.
8
-
9
- ## How it works
10
-
11
- Drafted frames render in iframes. The server injects a `<base>` tag so relative paths like `<link href="css/styles.css">` resolve to the frame's assets in R2 storage. You upload the HTML as a frame via `write`, and all referenced files (CSS, JS, images, fonts) as assets via `upload_asset` or `batch`.
12
-
13
- **Most modern sites are SPAs** (React, Framer, Webflow, Next.js) where the HTML source is just a shell and the real content is rendered by JavaScript. A simple HTTP fetch won't capture the content. This skill uses Puppeteer to render the page fully before capturing.
14
-
15
- ## Step 1: Create/open a project
16
-
17
- ```
18
- create_project({ name: "My Import" })
19
- open_project({ projectId: "..." })
20
- ```
21
-
22
- ## Step 2: Ensure Puppeteer is available
23
-
24
- ```bash
25
- npm ls puppeteer 2>/dev/null || npm install puppeteer
26
- ```
27
-
28
- ## Step 3: Render, capture, and download assets
29
-
30
- Run this script locally. It launches a headless browser, renders the page fully (including SPA content), captures the complete DOM, then downloads all external assets and rewrites URLs to relative paths.
31
-
32
- **Adapt `SITE_URL` as needed:**
33
-
34
- ```bash
35
- node -e "
36
- const puppeteer = require('puppeteer');
37
- const https = require('https');
38
- const http = require('http');
39
- const fs = require('fs');
40
- const path = require('path');
41
- const { URL } = require('url');
42
-
43
- const SITE_URL = 'https://example.com'; // ← CHANGE THIS
44
- const OUT_DIR = '/tmp/site-import';
45
-
46
- // ── Helpers ──────────────────────────────────────────────────────
47
-
48
- function download(url) {
49
- return new Promise((resolve, reject) => {
50
- const mod = url.startsWith('https') ? https : http;
51
- mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
52
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
53
- return download(res.headers.location).then(resolve, reject);
54
- }
55
- if (res.statusCode >= 400) return reject(new Error('HTTP ' + res.statusCode));
56
- const chunks = [];
57
- res.on('data', c => chunks.push(c));
58
- res.on('end', () => resolve(Buffer.concat(chunks)));
59
- res.on('error', reject);
60
- }).on('error', reject);
61
- });
62
- }
63
-
64
- function urlToAssetPath(urlStr) {
65
- try {
66
- const u = new URL(urlStr);
67
- const domain = u.hostname.replace(/[^a-z0-9.-]/g, '_');
68
- let p = u.pathname.replace(/[?#].*/, '');
69
- if (!p || p === '/') p = '/index';
70
- if (!path.extname(p)) {
71
- // Guess extension from common patterns
72
- if (urlStr.includes('font') || urlStr.includes('woff')) p += '.woff2';
73
- else if (urlStr.includes('css') || urlStr.includes('stylesheet')) p += '.css';
74
- else p += '.bin';
75
- }
76
- return 'assets/' + domain + p;
77
- } catch { return null; }
78
- }
79
-
80
- const MIME_BY_EXT = {
81
- '.css': 'text/css', '.js': 'application/javascript', '.mjs': 'application/javascript',
82
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
83
- '.webp': 'image/webp', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
84
- '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf',
85
- '.json': 'application/json', '.pdf': 'application/pdf',
86
- };
87
-
88
- // ── Main ─────────────────────────────────────────────────────────
89
-
90
- (async () => {
91
- fs.mkdirSync(OUT_DIR, { recursive: true });
92
-
93
- // 1. Render the page in a real browser
94
- console.log('Launching browser...');
95
- const browser = await puppeteer.launch({
96
- headless: true,
97
- args: ['--no-sandbox', '--disable-setuid-sandbox'],
98
- });
99
- const page = await browser.newPage();
100
- await page.setViewport({ width: 1440, height: 900 });
101
-
102
- console.log('Navigating to', SITE_URL);
103
- await page.goto(SITE_URL, { waitUntil: 'networkidle0', timeout: 30000 });
104
-
105
- // Scroll to bottom to trigger lazy-loaded content
106
- await page.evaluate(async () => {
107
- await new Promise(resolve => {
108
- let total = 0;
109
- const timer = setInterval(() => {
110
- window.scrollBy(0, 400);
111
- total += 400;
112
- if (total >= document.body.scrollHeight) { clearInterval(timer); resolve(); }
113
- }, 100);
114
- setTimeout(() => { clearInterval(timer); resolve(); }, 5000);
115
- });
116
- window.scrollTo(0, 0);
117
- });
118
- await new Promise(r => setTimeout(r, 1000)); // let lazy content settle
119
-
120
- // 2. Capture the fully rendered DOM
121
- let html = await page.content();
122
- const pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
123
- console.log('Captured rendered DOM (' + (html.length / 1024).toFixed(0) + ' KB, page height: ' + pageHeight + 'px)');
124
-
125
- // 3. Capture computed styles and inline them (prevents style loss)
126
- const inlineStyles = await page.evaluate(() => {
127
- const styles = [];
128
- for (const sheet of document.styleSheets) {
129
- try {
130
- const rules = Array.from(sheet.cssRules || []).map(r => r.cssText).join('\\n');
131
- if (rules) styles.push(rules);
132
- } catch { /* cross-origin stylesheet, skip */ }
133
- }
134
- return styles.join('\\n');
135
- });
136
-
137
- await browser.close();
138
-
139
- // 4. Find all external URLs in the rendered HTML + inline styles
140
- const combined = html + '\\n' + inlineStyles;
141
- const urlPattern = /(?:src|href|srcset)=[\"']?(https?:\\/\\/[^\"'\\s,>]+)[\"']?|url\\([\"']?(https?:\\/\\/[^\"')]+)[\"']?\\)/g;
142
- const skipDomains = ['google-analytics.com', 'googletagmanager.com', 'segment.com', 'hotjar.com', 'facebook.net', 'events.framer.com'];
143
- const assetExtensions = /\\.(css|js|mjs|json|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf|eot|mp4|webm|pdf)(\\?|$)/i;
144
-
145
- const assets = new Map();
146
- let match;
147
- while ((match = urlPattern.exec(combined)) !== null) {
148
- const url = (match[1] || match[2] || '').split(/[\\s,]/)[0]; // handle srcset
149
- if (!url || assets.has(url)) continue;
150
- try { if (skipDomains.some(d => url.includes(d))) continue; } catch {}
151
- if (!assetExtensions.test(url) && !url.includes('font') && !url.includes('woff')) continue;
152
- const assetPath = urlToAssetPath(url);
153
- if (assetPath) assets.set(url, assetPath);
154
- }
155
-
156
- console.log('Found', assets.size, 'assets to download');
157
-
158
- // 5. Download each asset
159
- let downloaded = 0, failed = 0;
160
- for (const [url, relPath] of assets) {
161
- const outPath = path.join(OUT_DIR, relPath);
162
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
163
- try {
164
- const data = await download(url);
165
- fs.writeFileSync(outPath, data);
166
- downloaded++;
167
- } catch (e) {
168
- console.error(' FAILED:', url.substring(0, 80), e.message);
169
- failed++;
170
- assets.delete(url); // remove so we don't rewrite to a missing file
171
- }
172
- }
173
- console.log('Downloaded:', downloaded, '| Failed:', failed);
174
-
175
- // 6. Rewrite URLs in HTML AND in downloaded text files (CSS, JS, MJS)
176
- const rewriteTargets = [{ content: html, path: path.join(OUT_DIR, 'index.html') }];
177
- for (const [, relPath] of assets) {
178
- const ext = path.extname(relPath).toLowerCase();
179
- if (['.css', '.js', '.mjs', '.json', '.svg'].includes(ext)) {
180
- const filePath = path.join(OUT_DIR, relPath);
181
- if (fs.existsSync(filePath)) {
182
- rewriteTargets.push({ content: fs.readFileSync(filePath, 'utf8'), path: filePath });
183
- }
184
- }
185
- }
186
-
187
- for (const target of rewriteTargets) {
188
- let content = target.content;
189
- for (const [url, relPath] of assets) {
190
- // In the main HTML, use relative paths directly
191
- // In asset files, compute the relative path from the asset to the referenced asset
192
- if (target.path.endsWith('index.html')) {
193
- content = content.split(url).join(relPath);
194
- } else {
195
- const fromDir = path.dirname(target.path.replace(OUT_DIR + '/', ''));
196
- const toPath = relPath;
197
- const rel = path.relative(fromDir, toPath);
198
- content = content.split(url).join(rel);
199
- }
200
- }
201
- if (target.path.endsWith('index.html')) html = content;
202
- fs.writeFileSync(target.path, content);
203
- }
204
-
205
- // 7. Inject captured styles into the HTML (ensures styles survive without external sheets)
206
- if (inlineStyles) {
207
- const styleTag = '<style id=\"captured-styles\">' + inlineStyles + '</style>';
208
- if (html.includes('</head>')) {
209
- html = html.replace('</head>', styleTag + '</head>');
210
- } else {
211
- html = styleTag + html;
212
- }
213
- }
214
-
215
- // 8. Clean up HTML
216
- html = html.replace(/<base[^>]*>/gi, '');
217
- html = html.replace(/<meta[^>]*Content-Security-Policy[^>]*>/gi, '');
218
- html = html.replace(/<script[^>]*(?:analytics|gtag|segment|hotjar|events\\.framer)[^>]*>.*?<\\/script>/gis, '');
219
- html = html.replace(/navigator\\.serviceWorker\\.register\\([^)]*\\)/g, '');
220
- html = html.replace(/\\/\\/# sourceMappingURL=[^\\n]*/g, '');
221
-
222
- // 9. Write final HTML
223
- fs.writeFileSync(path.join(OUT_DIR, 'index.html'), html);
224
-
225
- // 10. Write manifest
226
- const manifest = Array.from(assets.entries()).map(([url, relPath]) => ({
227
- asset_path: relPath,
228
- file_path: path.join(OUT_DIR, relPath),
229
- exists: fs.existsSync(path.join(OUT_DIR, relPath)),
230
- })).filter(a => a.exists);
231
- fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
232
-
233
- console.log('\\nOutput:', OUT_DIR);
234
- console.log('HTML:', path.join(OUT_DIR, 'index.html'), '(' + (html.length / 1024).toFixed(0) + ' KB)');
235
- console.log('Page height:', pageHeight, 'px (use this for frame height)');
236
- console.log('Manifest:', manifest.length, 'assets ready to upload');
237
- })().catch(e => { console.error(e); process.exit(1); });
238
- "
239
- ```
240
-
241
- This outputs:
242
- - `/tmp/site-import/index.html` — fully rendered HTML with relative paths and captured styles
243
- - `/tmp/site-import/assets/...` — all downloaded assets (with URLs rewritten inside CSS/JS files too)
244
- - `/tmp/site-import/manifest.json` — asset list for the upload step
245
- - The page height in pixels (use this for the frame height)
246
-
247
- **Key improvements over a plain HTTP fetch:**
248
- - Renders the page in a real browser — captures SPA content (React, Framer, Webflow, etc.)
249
- - Scrolls to trigger lazy-loaded images and content
250
- - Captures computed CSS and inlines it — styles survive even if external sheets fail
251
- - Rewrites URLs inside CSS and JS files, not just HTML
252
- - Handles `srcset` attributes
253
-
254
- ## Step 4: Write the frame
255
-
256
- Use `file_path` to write the HTML directly from disk — this handles large files that won't fit in a `content` parameter:
257
-
258
- ```
259
- write({
260
- path: "/designs/my-site/homepage.html",
261
- file_path: "/tmp/site-import/index.html",
262
- width: 1440,
263
- height: <page-height-from-script-output>
264
- })
265
- ```
266
-
267
- The `write` tool reads `.html` files as inline content (not binary), so the frame gets proper base tag injection and all relative asset paths will resolve. Note the frame ID in the response — you'll use it in the next step.
268
-
269
- **Use the page height from the script output** as the frame height so the full page is visible without scrolling within the frame.
270
-
271
- ## Step 5: Upload assets
272
-
273
- Read `manifest.json` and batch upload all assets:
274
-
275
- ```
276
- batch({
277
- operations: [
278
- { tool: "upload_asset", asset_path: "assets/fonts.gstatic.com/s/outfit/v14/abc.woff2", file_path: "/tmp/site-import/assets/fonts.gstatic.com/s/outfit/v14/abc.woff2", frame_id: "<frame-id>" },
279
- { tool: "upload_asset", asset_path: "assets/cdn.example.com/img/hero.png", file_path: "/tmp/site-import/assets/cdn.example.com/img/hero.png", frame_id: "<frame-id>" },
280
- // ... one entry per asset from manifest.json
281
- ]
282
- })
283
- ```
284
-
285
- Associate assets with the frame via `frame_id` so they're cleaned up if the frame is deleted.
286
-
287
- **For large manifests (30+ assets):** Split into batches of 20-30 operations to avoid timeouts.
288
-
289
- ## Step 6: Verify
290
-
291
- ```
292
- screenshot({ target: "/designs/my-site/homepage.html", fullPage: true })
293
- ```
294
-
295
- Check for missing images, broken styles, or layout issues. If assets are missing, check `list_assets({ frame_id: "..." })` against the manifest.
296
-
297
- ## For local build directories
298
-
299
- If importing from a local build output (e.g., `./dist` from Vite, `./build` from CRA):
300
-
301
- 1. Skip the Puppeteer step — read `index.html` directly
302
- 2. Find all asset references in the HTML
303
- 3. Copy assets to `/tmp/site-import/assets/` preserving relative paths
304
- 4. `write` the HTML + `batch upload_asset` the assets
305
-
306
- The Puppeteer approach is only needed for live URLs where JS rendering matters.
307
-
308
- ## Common issues
309
-
310
- | Problem | Cause | Fix |
311
- |---------|-------|-----|
312
- | Blank frame | CSP meta tag not stripped | Check the cleanup regex caught it |
313
- | Missing images | `srcset` or `<picture>` not captured | Add patterns for these to the URL regex |
314
- | Broken layout | Computed styles not captured | Check the inlined `<style id="captured-styles">` tag |
315
- | 404 on assets | URL rewriting missed some references | Check downloaded CSS/JS files for un-rewritten absolute URLs |
316
- | Frame too short | Used default height instead of page height | Use the page height from script output |
317
- | Puppeteer not found | Not installed | Run `npm install puppeteer` first |
File without changes